Add a form to edit wallet rules (#1345)
This commit is contained in:
parent
b781a011d8
commit
b4c99d8ea7
|
@ -71,7 +71,8 @@ export const createProposal = async (
|
|||
proposalIndex: number,
|
||||
instructionsData: InstructionDataWithHoldUpTime[],
|
||||
isDraft: boolean,
|
||||
client?: VotingClient
|
||||
client?: VotingClient,
|
||||
callbacks?: Parameters<typeof sendTransactionsV3>[0]['callbacks']
|
||||
): Promise<PublicKey> => {
|
||||
const instructions: TransactionInstruction[] = []
|
||||
|
||||
|
@ -227,6 +228,7 @@ export const createProposal = async (
|
|||
})
|
||||
|
||||
await sendTransactionsV3({
|
||||
callbacks,
|
||||
connection,
|
||||
wallet,
|
||||
transactionInstructions: txes,
|
||||
|
@ -279,6 +281,7 @@ export const createProposal = async (
|
|||
})
|
||||
|
||||
await sendTransactionsV3({
|
||||
callbacks,
|
||||
connection,
|
||||
wallet,
|
||||
transactionInstructions: txes,
|
||||
|
|
|
@ -12,6 +12,7 @@ import { VoteTipping } from '@solana/spl-governance'
|
|||
import cx from 'classnames'
|
||||
import React, { useState } from 'react'
|
||||
import { BigNumber } from 'bignumber.js'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { formatNumber } from '@utils/formatNumber'
|
||||
import { ntext } from '@utils/ntext'
|
||||
|
@ -21,6 +22,7 @@ import useRealm from '@hooks/useRealm'
|
|||
import Tooltip from '@components/Tooltip'
|
||||
import { DISABLED_VOTER_WEIGHT } from '@tools/constants'
|
||||
import Address from '@components/Address'
|
||||
import useQueryContext from '@hooks/useQueryContext'
|
||||
|
||||
import Section from '../../../Section'
|
||||
import TokenIcon from '../../../../icons/TokenIcon'
|
||||
|
@ -66,6 +68,17 @@ export function durationStr(duration: number, short = false) {
|
|||
return count + (short ? 's' : ' ' + ntext(count, 'second'))
|
||||
}
|
||||
|
||||
function votingLengthText(time: number) {
|
||||
const hours = time / UNIX_HOUR
|
||||
const days = Math.floor(hours / 24)
|
||||
const remainingHours = (time - days * UNIX_DAY) / UNIX_HOUR
|
||||
|
||||
return (
|
||||
durationStr(days * UNIX_DAY) +
|
||||
(remainingHours ? ` ${durationStr(remainingHours * UNIX_HOUR)}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
wallet: Wallet
|
||||
|
@ -73,7 +86,9 @@ interface Props {
|
|||
|
||||
export default function Rules(props: Props) {
|
||||
const [editRulesOpen, setEditRulesOpen] = useState(false)
|
||||
const { ownVoterWeight } = useRealm()
|
||||
const { ownVoterWeight, symbol } = useRealm()
|
||||
const router = useRouter()
|
||||
const { fmtUrlWithCluster } = useQueryContext()
|
||||
|
||||
const programVersion = useProgramVersion()
|
||||
|
||||
|
@ -123,7 +138,15 @@ export default function Rules(props: Props) {
|
|||
'disabled:opacity-50'
|
||||
)}
|
||||
disabled={!canEditRules}
|
||||
onClick={() => setEditRulesOpen(true)}
|
||||
onClick={() => {
|
||||
if (props.wallet.governanceAccount) {
|
||||
router.push(
|
||||
fmtUrlWithCluster(
|
||||
`/realm/${symbol}/governance/${props.wallet.governanceAccount.pubkey.toBase58()}/edit`
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4 stroke-primary-light" />
|
||||
<div>Edit Rules</div>
|
||||
|
@ -138,8 +161,17 @@ export default function Rules(props: Props) {
|
|||
<div className="grid grid-cols-2 gap-8">
|
||||
<Section
|
||||
icon={<CalendarIcon />}
|
||||
name="Max Voting Time"
|
||||
value={durationStr(props.wallet.rules.common.maxVotingTime)}
|
||||
name="Unrestricted Voting Time"
|
||||
value={votingLengthText(
|
||||
props.wallet.rules.common.maxVotingTime
|
||||
)}
|
||||
/>
|
||||
<Section
|
||||
icon={<CalendarIcon />}
|
||||
name="Voting Cool-Off Time"
|
||||
value={durationStr(
|
||||
props.wallet.rules.common.votingCoolOffSeconds
|
||||
)}
|
||||
/>
|
||||
<Section
|
||||
icon={<ClockIcon />}
|
||||
|
|
|
@ -17,6 +17,7 @@ export function getRulesFromAccount(
|
|||
rules.common = {
|
||||
maxVotingTime: govConfig.maxVotingTime,
|
||||
minInstructionHoldupTime: govConfig.minInstructionHoldUpTime,
|
||||
votingCoolOffSeconds: govConfig.votingCoolOffTime,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,10 @@
|
|||
"tsconfigRootDir": ".",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint/eslint-plugin", "import"],
|
||||
"plugins": [
|
||||
"@typescript-eslint/eslint-plugin",
|
||||
"import"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
|
@ -15,7 +18,10 @@
|
|||
"node": true,
|
||||
"jest": true
|
||||
},
|
||||
"ignorePatterns": [".eslintrc.js", "migrations"],
|
||||
"ignorePatterns": [
|
||||
".eslintrc.js",
|
||||
"migrations"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/interface-name-prefix": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
|
@ -27,7 +33,9 @@
|
|||
"error",
|
||||
{
|
||||
"newlines-between": "always-and-inside-groups",
|
||||
"pathGroupsExcludedImportTypes": ["builtin"],
|
||||
"pathGroupsExcludedImportTypes": [
|
||||
"builtin"
|
||||
],
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "@hub/**/**",
|
||||
|
@ -52,6 +60,10 @@
|
|||
{
|
||||
"pattern": "@verify-wallet/**/**",
|
||||
"group": "parent"
|
||||
},
|
||||
{
|
||||
"pattern": "@hooks/**/**",
|
||||
"group": "parent"
|
||||
}
|
||||
],
|
||||
"alphabetize": {
|
||||
|
|
25
hub/App.tsx
25
hub/App.tsx
|
@ -1,6 +1,7 @@
|
|||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import Script from 'next/script';
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { GlobalHeader } from '@hub/components/GlobalHeader';
|
||||
import { MinimalHeader } from '@hub/components/GlobalHeader/MinimalHeader';
|
||||
|
@ -56,9 +57,31 @@ interface Props {
|
|||
}
|
||||
|
||||
export function App(props: Props) {
|
||||
const router = useRouter();
|
||||
const isDarkMode = router.pathname.startsWith('/realm/[id]/governance');
|
||||
|
||||
useEffect(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
return (
|
||||
<RootProvider>
|
||||
<Head>
|
||||
{isDarkMode && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
html {
|
||||
background-color: #171717;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="57x57"
|
||||
|
|
|
@ -57,6 +57,7 @@ export function AuthorAvatar(props: Props) {
|
|||
'rounded-full',
|
||||
'border',
|
||||
'border-neutral-400',
|
||||
'dark:border-neutral-600',
|
||||
props.className,
|
||||
)}
|
||||
src={props.author.civicInfo.avatarUrl}
|
||||
|
@ -69,6 +70,7 @@ export function AuthorAvatar(props: Props) {
|
|||
'rounded-full',
|
||||
'border',
|
||||
'border-neutral-400',
|
||||
'dark:border-neutral-600',
|
||||
props.className,
|
||||
)}
|
||||
src={props.author.twitterInfo.avatarUrl}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { PublicKey } from '@solana/web3.js';
|
||||
|
||||
import { Realm } from '../gql';
|
||||
import { SmallCard } from '@hub/components/DiscoverPage/SmallCard';
|
||||
import { NFT as NFTIcon } from '@hub/components/icons/NFT';
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ function buildFormStateFromData(data: gql.DiscoverPage) {
|
|||
function removeExtraneousFields<T extends { __typename?: string }>(
|
||||
item: T,
|
||||
): Omit<T, '__typename'> {
|
||||
// eslint-disable-next-line
|
||||
const { __typename, ...rest } = item;
|
||||
return rest;
|
||||
}
|
||||
|
@ -106,8 +107,6 @@ export function EditDiscoverPage(props: Props) {
|
|||
}
|
||||
}, [result]);
|
||||
|
||||
console.log(result);
|
||||
|
||||
return (
|
||||
<article className={props.className}>
|
||||
{pipe(
|
||||
|
@ -283,6 +282,7 @@ export function EditDiscoverPage(props: Props) {
|
|||
(r) => r.publicKey,
|
||||
),
|
||||
spotlight: (formState?.spotlight || []).map((s) => {
|
||||
// eslint-disable-next-line
|
||||
const { realm, ...rest } = s;
|
||||
return removeExtraneousFields({
|
||||
...rest,
|
||||
|
|
|
@ -25,6 +25,7 @@ function trimFAQ(
|
|||
return (!!item.answer && !isEmpty(item.answer)) || !!item.question;
|
||||
})
|
||||
.map((item) => {
|
||||
// eslint-disable-next-line
|
||||
const { __typename, clippedAnswer, ...rest } = item;
|
||||
return rest;
|
||||
});
|
||||
|
|
|
@ -2,10 +2,6 @@ import ImageIcon from '@carbon/icons-react/lib/Image';
|
|||
import { produce } from 'immer';
|
||||
|
||||
import { FieldDescription } from '../common/FieldDescription';
|
||||
import { FieldHeader } from '../common/FieldHeader';
|
||||
import { FieldIconPreview } from '../common/FieldIconPreview';
|
||||
import { SecondaryRed } from '@hub/components/controls/Button';
|
||||
import { Input } from '@hub/components/controls/Input';
|
||||
|
||||
import { Item } from './Item';
|
||||
|
||||
|
@ -28,6 +24,7 @@ function trimGallery(
|
|||
return !!g.url;
|
||||
})
|
||||
.map((g) => {
|
||||
// eslint-disable-next-line
|
||||
const { __typename, ...rest } = g;
|
||||
return rest;
|
||||
});
|
||||
|
|
|
@ -30,6 +30,7 @@ function trimAbout(
|
|||
return !isEmpty(a.content) || !!a.heading;
|
||||
})
|
||||
.map((a) => {
|
||||
// eslint-disable-next-line
|
||||
const { __typename, ...rest } = a;
|
||||
return rest;
|
||||
});
|
||||
|
@ -52,6 +53,7 @@ function trimResources(
|
|||
return !!r.url || !!r.title;
|
||||
})
|
||||
.map((r) => {
|
||||
// eslint-disable-next-line
|
||||
const { __typename, ...rest } = r;
|
||||
return {
|
||||
...rest,
|
||||
|
|
|
@ -74,6 +74,7 @@ function trimRoadmap(roadmap: {
|
|||
return !!item.date || !!item.resource || !!item.status || !!item.title;
|
||||
})
|
||||
.map((item) => {
|
||||
// eslint-disable-next-line
|
||||
const { __typename, ...rest } = item;
|
||||
return rest;
|
||||
});
|
||||
|
|
|
@ -37,6 +37,7 @@ function trimTeam(
|
|||
);
|
||||
})
|
||||
.map((t) => {
|
||||
// eslint-disable-next-line
|
||||
const { __typename, ...rest } = t;
|
||||
return rest;
|
||||
});
|
||||
|
|
|
@ -35,6 +35,7 @@ function removeClippedAnswer<
|
|||
O extends { clippedAnswer?: any; [key: string]: any }
|
||||
>(item: O): Omit<O, 'clippedAnswer'> {
|
||||
if ('clippedAnswer' in item) {
|
||||
// eslint-disable-next-line
|
||||
const { clippedAnswer, ...rest } = item;
|
||||
return rest;
|
||||
}
|
||||
|
@ -46,6 +47,7 @@ function removeTypename<O extends { __typename?: string; [key: string]: any }>(
|
|||
item: O,
|
||||
): Omit<O, '__typename'> {
|
||||
if ('__typename' in item) {
|
||||
// eslint-disable-next-line
|
||||
const { __typename, ...rest } = item;
|
||||
return rest;
|
||||
}
|
||||
|
@ -57,6 +59,7 @@ function removeTwitterFollowerCount<
|
|||
O extends { twitterFollowerCount?: any; [key: string]: any }
|
||||
>(item: O): Omit<O, 'twitterFollowerCount'> {
|
||||
if ('twitterFollowerCount' in item) {
|
||||
// eslint-disable-next-line
|
||||
const { twitterFollowerCount, ...rest } = item;
|
||||
return rest;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import { SectionBlock } from '../SectionBlock';
|
||||
import { ValueBlock } from '../ValueBlock';
|
||||
import { Input } from '@hub/components/controls/Input';
|
||||
import cx from '@hub/lib/cx';
|
||||
import { formatNumber } from '@hub/lib/formatNumber';
|
||||
import { FormProps } from '@hub/types/FormProps';
|
||||
|
||||
interface Props
|
||||
extends FormProps<{
|
||||
depositExemptProposalCount: number;
|
||||
minInstructionHoldupDays: number;
|
||||
}> {
|
||||
className?: string;
|
||||
programVersion: number;
|
||||
}
|
||||
|
||||
export function AdvancedOptions(props: Props) {
|
||||
return (
|
||||
<SectionBlock className={cx(props.className, 'space-y-8')}>
|
||||
<ValueBlock
|
||||
title="Deposit Exempt Proposal Count"
|
||||
description="The amount of proposals a member can create without a deposit."
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full pr-24"
|
||||
placeholder="# of proposals"
|
||||
value={formatNumber(props.depositExemptProposalCount, undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
onChange={(e) => {
|
||||
const text = e.currentTarget.value.replaceAll(/[^\d.-]/g, '');
|
||||
const val = parseInt(text || '0', 10);
|
||||
props.onDepositExemptProposalCountChange?.(val);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
|
||||
Proposals
|
||||
</div>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
<ValueBlock
|
||||
title="Minimum Instruction Holdup Time"
|
||||
description="The minimum time which must pass before proposal instructions can be executed."
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full pr-24"
|
||||
placeholder="# of days"
|
||||
value={formatNumber(props.minInstructionHoldupDays, undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
onChange={(e) => {
|
||||
const text = e.currentTarget.value.replaceAll(/[^\d.-]/g, '');
|
||||
const val = parseInt(text || '0', 10);
|
||||
props.onMinInstructionHoldupDaysChange?.(val);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
|
||||
Days
|
||||
</div>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
</SectionBlock>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,362 @@
|
|||
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
|
||||
import UserMultipleIcon from '@carbon/icons-react/lib/UserMultiple';
|
||||
import { BigNumber } from 'bignumber.js';
|
||||
import { produce } from 'immer';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { SectionBlock } from '../SectionBlock';
|
||||
import { SectionHeader } from '../SectionHeader';
|
||||
import { SliderValue } from '../SliderValue';
|
||||
import { CommunityRules, CouncilRules } from '../types';
|
||||
import { ValueBlock } from '../ValueBlock';
|
||||
import { VoteTippingSelector } from '../VoteTippingSelector';
|
||||
import { ButtonToggle } from '@hub/components/controls/ButtonToggle';
|
||||
import { Input } from '@hub/components/controls/Input';
|
||||
import { Slider } from '@hub/components/controls/Slider';
|
||||
import cx from '@hub/lib/cx';
|
||||
import { formatNumber } from '@hub/lib/formatNumber';
|
||||
import { FormProps } from '@hub/types/FormProps';
|
||||
|
||||
interface Props
|
||||
extends FormProps<{
|
||||
communityRules: CommunityRules;
|
||||
}> {
|
||||
currentCommunityRules: CommunityRules;
|
||||
currentCouncilRules?: CouncilRules;
|
||||
className?: string;
|
||||
programVersion: number;
|
||||
}
|
||||
|
||||
export function CommunityDetails(props: Props) {
|
||||
const communityPowerPercent = props.communityRules.votingPowerToCreateProposals
|
||||
.dividedBy(props.communityRules.totalSupply)
|
||||
.multipliedBy(100);
|
||||
|
||||
const [additionalOptionsExpanded, setAdditionalOptionsExpanded] = useState(
|
||||
false,
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionBlock className={props.className}>
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
icon={<UserMultipleIcon />}
|
||||
text="Community Details"
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
{!!props.currentCouncilRules &&
|
||||
props.currentCouncilRules.canCreateProposal && (
|
||||
<ValueBlock
|
||||
title="Do you want to allow community members to create proposals?"
|
||||
description="If disabled, the community members can no longer create proposals."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.communityRules.canCreateProposal}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.canCreateProposal = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{props.communityRules.canCreateProposal && (
|
||||
<ValueBlock
|
||||
title="What is the minimum amount of community governance power required to create a proposal?"
|
||||
description="A user must have this many community governance power in order to create a proposal."
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full pr-24"
|
||||
placeholder="amount of governance power"
|
||||
value={formatNumber(
|
||||
props.communityRules.votingPowerToCreateProposals,
|
||||
undefined,
|
||||
{
|
||||
maximumFractionDigits: 0,
|
||||
},
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const text = e.currentTarget.value.replaceAll(/[^\d.-]/g, '');
|
||||
const value = text ? new BigNumber(text) : new BigNumber(0);
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.votingPowerToCreateProposals = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
|
||||
Tokens
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{props.communityRules.totalSupply.isGreaterThan(0) && (
|
||||
<div className="mt-1 text-xs text-neutral-500">
|
||||
{communityPowerPercent.isGreaterThan(0)
|
||||
? communityPowerPercent.isLessThan(0.01)
|
||||
? '<0.01'
|
||||
: formatNumber(communityPowerPercent, undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
})
|
||||
: 0}
|
||||
% of token supply
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!!props.currentCouncilRules && props.currentCouncilRules.canVote && (
|
||||
<ValueBlock
|
||||
title="Do you want to allow community members to vote?"
|
||||
description="If disabled, the community members can no longer vote on proposals."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.communityRules.canVote}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.canVote = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{props.communityRules.canVote && (
|
||||
<>
|
||||
<ValueBlock
|
||||
title="Community Approval Quorum"
|
||||
description="The percentage of Yes votes required to pass a proposal"
|
||||
>
|
||||
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
|
||||
<SliderValue
|
||||
min={1}
|
||||
max={100}
|
||||
value={props.communityRules.quorumPercent}
|
||||
units="%"
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.quorumPercent = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
trackColor="bg-sky-400"
|
||||
value={props.communityRules.quorumPercent}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.quorumPercent = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
onRenderValue={(val) => `${val}%`}
|
||||
/>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
<ValueBlock
|
||||
title="Community Vote Tipping"
|
||||
description="Decide when voting should end"
|
||||
>
|
||||
<VoteTippingSelector
|
||||
className="w-full"
|
||||
value={props.communityRules.voteTipping}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.voteTipping = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
</>
|
||||
)}
|
||||
{!!props.currentCouncilRules && props.currentCouncilRules.canVote && (
|
||||
<ValueBlock
|
||||
title="Do you want your community to have veto power over council proposals?"
|
||||
description="Your community can veto a council-approved proposal."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.communityRules.canVeto}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.canVeto = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!!props.currentCouncilRules &&
|
||||
props.currentCouncilRules.canVote &&
|
||||
props.communityRules.canVeto && (
|
||||
<ValueBlock
|
||||
title="Community Veto Voting Quorum"
|
||||
description={
|
||||
<>
|
||||
The percentage of <span className="font-bold">No</span> votes
|
||||
required to veto a council proposal
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
|
||||
<SliderValue
|
||||
min={1}
|
||||
max={100}
|
||||
value={props.communityRules.vetoQuorumPercent}
|
||||
units="%"
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.vetoQuorumPercent = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
trackColor="bg-sky-400"
|
||||
value={props.communityRules.vetoQuorumPercent}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.vetoQuorumPercent = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
onRenderValue={(val) => `${val}%`}
|
||||
/>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!!props.currentCouncilRules &&
|
||||
(!props.currentCouncilRules.canVote ||
|
||||
!props.currentCouncilRules.canCreateProposal) && (
|
||||
<button
|
||||
className="flex items-center text-sm text-neutral-500"
|
||||
onClick={() => setAdditionalOptionsExpanded((cur) => !cur)}
|
||||
>
|
||||
Additional options{' '}
|
||||
<ChevronDownIcon
|
||||
className={cx(
|
||||
'fill-current',
|
||||
'h-4',
|
||||
'transition-transform',
|
||||
'w-4',
|
||||
additionalOptionsExpanded && '-rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{additionalOptionsExpanded && (
|
||||
<>
|
||||
{!!props.currentCouncilRules &&
|
||||
!props.currentCouncilRules.canCreateProposal && (
|
||||
<ValueBlock
|
||||
title="Do you want to allow community members to create proposals?"
|
||||
description="If disabled, the community members can no longer create proposals."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.communityRules.canCreateProposal}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.canCreateProposal = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!!props.currentCouncilRules && !props.currentCouncilRules.canVote && (
|
||||
<ValueBlock
|
||||
title="Do you want to allow community members to vote?"
|
||||
description="If disabled, the community members can no longer vote on proposals."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.communityRules.canVote}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.canVote = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!!props.currentCouncilRules && !props.currentCouncilRules.canVote && (
|
||||
<ValueBlock
|
||||
title="Do you want your community to have veto power over council proposals?"
|
||||
description="Your community can veto a council-approved proposal."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.communityRules.canVeto}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.communityRules, (data) => {
|
||||
data.canVeto = value;
|
||||
});
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!!props.currentCouncilRules &&
|
||||
!props.currentCouncilRules.canVote &&
|
||||
props.communityRules.canVeto && (
|
||||
<ValueBlock
|
||||
title="Community Veto Voting Quorum"
|
||||
description={
|
||||
<>
|
||||
The percentage of <span className="font-bold">No</span>{' '}
|
||||
votes required to veto a council proposal
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
|
||||
<SliderValue
|
||||
min={1}
|
||||
max={100}
|
||||
value={props.communityRules.vetoQuorumPercent}
|
||||
units="%"
|
||||
onChange={(value) => {
|
||||
const newRules = produce(
|
||||
props.communityRules,
|
||||
(data) => {
|
||||
data.vetoQuorumPercent = value;
|
||||
},
|
||||
);
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
trackColor="bg-sky-400"
|
||||
value={props.communityRules.vetoQuorumPercent}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(
|
||||
props.communityRules,
|
||||
(data) => {
|
||||
data.vetoQuorumPercent = value;
|
||||
},
|
||||
);
|
||||
props.onCommunityRulesChange?.(newRules);
|
||||
}}
|
||||
onRenderValue={(val) => `${val}%`}
|
||||
/>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SectionBlock>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,406 @@
|
|||
import BuildingIcon from '@carbon/icons-react/lib/Building';
|
||||
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
|
||||
import { BigNumber } from 'bignumber.js';
|
||||
import { produce } from 'immer';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { SectionBlock } from '../SectionBlock';
|
||||
import { SectionHeader } from '../SectionHeader';
|
||||
import { SliderValue } from '../SliderValue';
|
||||
import { CommunityRules, CouncilRules } from '../types';
|
||||
import { ValueBlock } from '../ValueBlock';
|
||||
import { VoteTippingSelector } from '../VoteTippingSelector';
|
||||
import { ButtonToggle } from '@hub/components/controls/ButtonToggle';
|
||||
import { Input } from '@hub/components/controls/Input';
|
||||
import { Slider } from '@hub/components/controls/Slider';
|
||||
import cx from '@hub/lib/cx';
|
||||
import { formatNumber } from '@hub/lib/formatNumber';
|
||||
import { FormProps } from '@hub/types/FormProps';
|
||||
|
||||
interface Props
|
||||
extends FormProps<{
|
||||
councilRules: NonNullable<CouncilRules>;
|
||||
}> {
|
||||
className?: string;
|
||||
currentCommunityRules: CommunityRules;
|
||||
currentCouncilRules: NonNullable<CouncilRules>;
|
||||
programVersion: number;
|
||||
}
|
||||
|
||||
export function CouncilDetails(props: Props) {
|
||||
const councilPowerPercent = props.councilRules.votingPowerToCreateProposals
|
||||
.dividedBy(props.councilRules.totalSupply)
|
||||
.multipliedBy(100);
|
||||
|
||||
const [additionalOptionsExpanded, setAdditionalOptionsExpanded] = useState(
|
||||
false,
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionBlock className={props.className}>
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
icon={<BuildingIcon />}
|
||||
text="Council Details"
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
{props.currentCommunityRules.canCreateProposal && (
|
||||
<ValueBlock
|
||||
title="Do you want to allow council members to create proposals?"
|
||||
description="If disabled, the council members can no longer create proposals."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.councilRules.canCreateProposal}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.canCreateProposal = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{props.currentCommunityRules.canCreateProposal &&
|
||||
props.councilRules.canCreateProposal && (
|
||||
<ValueBlock
|
||||
title="What is the minimum amount of council governance power required to create a proposal?"
|
||||
description="A user must have this many council governance power in order to create a proposal."
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full pr-24"
|
||||
placeholder="amount of governance power"
|
||||
value={formatNumber(
|
||||
props.councilRules.votingPowerToCreateProposals,
|
||||
undefined,
|
||||
{
|
||||
maximumFractionDigits: 0,
|
||||
},
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const text = e.currentTarget.value.replaceAll(
|
||||
/[^\d.-]/g,
|
||||
'',
|
||||
);
|
||||
const value = text ? new BigNumber(text) : new BigNumber(0);
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.votingPowerToCreateProposals = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
|
||||
Tokens
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{props.councilRules.totalSupply.isGreaterThan(0) && (
|
||||
<div className="mt-1 text-xs text-neutral-500">
|
||||
{councilPowerPercent.isGreaterThan(0)
|
||||
? councilPowerPercent.isLessThan(0.01)
|
||||
? '<0.01'
|
||||
: formatNumber(councilPowerPercent, undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
})
|
||||
: 0}
|
||||
% of token supply
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{props.currentCommunityRules.canVote && (
|
||||
<ValueBlock
|
||||
title="Do you want to allow council members to vote?"
|
||||
description="If disabled, the council members can no longer vote on proposals."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.councilRules.canVote}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.canVote = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{props.councilRules.canVote && (
|
||||
<>
|
||||
<ValueBlock
|
||||
title="Council Approval Quorum"
|
||||
description="The percentage of Yes votes required to pass a proposal"
|
||||
>
|
||||
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
|
||||
<SliderValue
|
||||
min={1}
|
||||
max={100}
|
||||
value={props.councilRules.quorumPercent}
|
||||
units="%"
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.quorumPercent = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
trackColor="bg-sky-400"
|
||||
value={props.councilRules.quorumPercent}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.quorumPercent = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
onRenderValue={(val) => `${val}%`}
|
||||
/>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
<ValueBlock
|
||||
title="Council Vote Tipping"
|
||||
description="Decide when voting should end"
|
||||
>
|
||||
<VoteTippingSelector
|
||||
className="w-full"
|
||||
value={props.councilRules.voteTipping}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.voteTipping = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
</>
|
||||
)}
|
||||
{props.currentCommunityRules.canVote && (
|
||||
<ValueBlock
|
||||
title="Do you want your council to have veto power over community proposals?"
|
||||
description="Your council can veto a community-approved proposal."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.councilRules.canVeto}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.canVeto = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{props.currentCommunityRules.canVote && props.councilRules.canVeto && (
|
||||
<ValueBlock
|
||||
title="Council Veto Voting Quorum"
|
||||
description={
|
||||
<>
|
||||
The percentage of <span className="font-bold">No</span> votes
|
||||
required to veto a community proposal
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
|
||||
<SliderValue
|
||||
min={1}
|
||||
max={100}
|
||||
value={props.councilRules.vetoQuorumPercent}
|
||||
units="%"
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.vetoQuorumPercent = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
trackColor="bg-sky-400"
|
||||
value={props.councilRules.vetoQuorumPercent}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.vetoQuorumPercent = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
onRenderValue={(val) => `${val}%`}
|
||||
/>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{(!props.currentCommunityRules.canCreateProposal ||
|
||||
!props.currentCommunityRules.canVote) && (
|
||||
<button
|
||||
className="flex items-center text-sm text-neutral-500"
|
||||
onClick={() => setAdditionalOptionsExpanded((cur) => !cur)}
|
||||
>
|
||||
Additional options{' '}
|
||||
<ChevronDownIcon
|
||||
className={cx(
|
||||
'fill-current',
|
||||
'h-4',
|
||||
'transition-transform',
|
||||
'w-4',
|
||||
additionalOptionsExpanded && '-rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{additionalOptionsExpanded && (
|
||||
<>
|
||||
{!props.currentCommunityRules.canCreateProposal && (
|
||||
<ValueBlock
|
||||
title="Do you want to allow council members to create proposals?"
|
||||
description="If disabled, the council members can no longer create proposals."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.councilRules.canCreateProposal}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.canCreateProposal = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!props.currentCommunityRules.canCreateProposal &&
|
||||
props.councilRules.canCreateProposal && (
|
||||
<ValueBlock
|
||||
title="What is the minimum amount of council governance power required to create a proposal?"
|
||||
description="A user must have this many council governance power in order to create a proposal."
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full pr-24"
|
||||
placeholder="amount of governance power"
|
||||
value={formatNumber(
|
||||
props.councilRules.votingPowerToCreateProposals,
|
||||
undefined,
|
||||
{
|
||||
maximumFractionDigits: 0,
|
||||
},
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const text = e.currentTarget.value.replaceAll(
|
||||
/[^\d.-]/g,
|
||||
'',
|
||||
);
|
||||
const value = text
|
||||
? new BigNumber(text)
|
||||
: new BigNumber(0);
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.votingPowerToCreateProposals = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
|
||||
Tokens
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{props.councilRules.totalSupply.isGreaterThan(0) && (
|
||||
<div className="mt-1 text-xs text-neutral-500">
|
||||
{councilPowerPercent.isGreaterThan(0)
|
||||
? councilPowerPercent.isLessThan(0.01)
|
||||
? '<0.01'
|
||||
: formatNumber(councilPowerPercent, undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
})
|
||||
: 0}
|
||||
% of token supply
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!props.currentCommunityRules.canVote && (
|
||||
<ValueBlock
|
||||
title="Do you want to allow council members to vote?"
|
||||
description="If disabled, the council members can no longer vote on proposals."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.councilRules.canVote}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.canVote = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!props.currentCommunityRules.canVote && (
|
||||
<ValueBlock
|
||||
title="Do you want your council to have veto power over community proposals?"
|
||||
description="Your council can veto a community-approved proposal."
|
||||
>
|
||||
<ButtonToggle
|
||||
className="h-14"
|
||||
value={props.councilRules.canVeto}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.canVeto = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{!props.currentCommunityRules.canVote &&
|
||||
props.councilRules.canVeto && (
|
||||
<ValueBlock
|
||||
title="Council Veto Voting Quorum"
|
||||
description={
|
||||
<>
|
||||
The percentage of <span className="font-bold">No</span>{' '}
|
||||
votes required to veto a community proposal
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
|
||||
<SliderValue
|
||||
min={1}
|
||||
max={100}
|
||||
value={props.councilRules.vetoQuorumPercent}
|
||||
units="%"
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.vetoQuorumPercent = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
trackColor="bg-sky-400"
|
||||
value={props.councilRules.vetoQuorumPercent}
|
||||
onChange={(value) => {
|
||||
const newRules = produce(props.councilRules, (data) => {
|
||||
data.vetoQuorumPercent = value;
|
||||
});
|
||||
props.onCouncilRulesChange?.(newRules);
|
||||
}}
|
||||
onRenderValue={(val) => `${val}%`}
|
||||
/>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SectionBlock>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
|
||||
import type { PublicKey } from '@solana/web3.js';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AdvancedOptions } from '../AdvancedOptions';
|
||||
import { CommunityDetails } from '../CommunityDetails';
|
||||
import { CouncilDetails } from '../CouncilDetails';
|
||||
import { CommunityRules, CouncilRules } from '../types';
|
||||
import { VotingDuration } from '../VotingDuration';
|
||||
import { WalletDescription } from '../WalletDescription';
|
||||
import cx from '@hub/lib/cx';
|
||||
import { FormProps } from '@hub/types/FormProps';
|
||||
|
||||
interface Props
|
||||
extends FormProps<{
|
||||
communityRules: CommunityRules;
|
||||
councilRules: CouncilRules;
|
||||
coolOffHours: number;
|
||||
depositExemptProposalCount: number;
|
||||
maxVoteDays: number;
|
||||
minInstructionHoldupDays: number;
|
||||
}> {
|
||||
className?: string;
|
||||
currentCommunityRules: CommunityRules;
|
||||
currentCouncilRules: CouncilRules;
|
||||
governanceAddress: PublicKey;
|
||||
programVersion: number;
|
||||
walletAddress: PublicKey;
|
||||
}
|
||||
|
||||
export function Form(props: Props) {
|
||||
const [showCouncilOptions, setShowCouncilOptions] = useState(
|
||||
props.currentCouncilRules?.canVote || false,
|
||||
);
|
||||
const [showCommunityOptions, setShowCommunityOptions] = useState(
|
||||
props.currentCommunityRules.canVote,
|
||||
);
|
||||
const [showAdvanceOptions, setShowAdvanceOptions] = useState(false);
|
||||
const showCommunityFirst = props.currentCommunityRules.canVote;
|
||||
|
||||
return (
|
||||
<article className={props.className}>
|
||||
<WalletDescription
|
||||
className="mb-3"
|
||||
governanceAddress={props.governanceAddress}
|
||||
walletAddress={props.walletAddress}
|
||||
/>
|
||||
<h1 className="text-5xl font-medium m-0 mb-4 dark:text-white ">
|
||||
What changes would you like to make to this wallet?
|
||||
</h1>
|
||||
<p className="m-0 mb-16 dark:text-neutral-300">
|
||||
Submitting updates to a wallet’s rules will create a proposal for the
|
||||
DAO to vote on. If approved, the updates will be ready to be executed.
|
||||
</p>
|
||||
<div>
|
||||
<VotingDuration
|
||||
className="mb-8"
|
||||
coolOffHours={props.coolOffHours}
|
||||
maxVoteDays={props.maxVoteDays}
|
||||
programVersion={props.programVersion}
|
||||
onCoolOffHoursChange={props.onCoolOffHoursChange}
|
||||
onMaxVoteDaysChange={props.onMaxVoteDaysChange}
|
||||
/>
|
||||
{showCommunityOptions && showCommunityFirst && (
|
||||
<CommunityDetails
|
||||
className="mb-8"
|
||||
communityRules={props.communityRules}
|
||||
currentCommunityRules={props.currentCommunityRules}
|
||||
currentCouncilRules={props.currentCouncilRules}
|
||||
programVersion={props.programVersion}
|
||||
onCommunityRulesChange={props.onCommunityRulesChange}
|
||||
/>
|
||||
)}
|
||||
{!props.currentCouncilRules?.canVote && props.currentCouncilRules && (
|
||||
<button
|
||||
className="flex items-center text-sm text-neutral-500 mt-16 mb-2.5"
|
||||
onClick={() => setShowCouncilOptions((cur) => !cur)}
|
||||
>
|
||||
Council Options{' '}
|
||||
<ChevronDownIcon
|
||||
className={cx(
|
||||
'fill-current',
|
||||
'h-4',
|
||||
'transition-transform',
|
||||
'w-4',
|
||||
showCouncilOptions && '-rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{showCouncilOptions &&
|
||||
props.councilRules &&
|
||||
props.currentCouncilRules && (
|
||||
<CouncilDetails
|
||||
className="mb-8"
|
||||
councilRules={props.councilRules}
|
||||
currentCouncilRules={props.currentCouncilRules}
|
||||
currentCommunityRules={props.currentCommunityRules}
|
||||
programVersion={props.programVersion}
|
||||
onCouncilRulesChange={props.onCouncilRulesChange}
|
||||
/>
|
||||
)}
|
||||
{!props.currentCommunityRules.canVote && !showCommunityFirst && (
|
||||
<button
|
||||
className="flex items-center text-sm text-neutral-500 mt-16 mb-2.5"
|
||||
onClick={() => setShowCommunityOptions((cur) => !cur)}
|
||||
>
|
||||
Community Options{' '}
|
||||
<ChevronDownIcon
|
||||
className={cx(
|
||||
'fill-current',
|
||||
'h-4',
|
||||
'transition-transform',
|
||||
'w-4',
|
||||
showCommunityOptions && '-rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{showCommunityOptions && !showCommunityFirst && (
|
||||
<CommunityDetails
|
||||
communityRules={props.communityRules}
|
||||
currentCommunityRules={props.currentCommunityRules}
|
||||
currentCouncilRules={props.currentCouncilRules}
|
||||
programVersion={props.programVersion}
|
||||
onCommunityRulesChange={props.onCommunityRulesChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
<button
|
||||
className="flex items-center text-sm text-neutral-500"
|
||||
onClick={() => setShowAdvanceOptions((cur) => !cur)}
|
||||
>
|
||||
Advanced Options{' '}
|
||||
<ChevronDownIcon
|
||||
className={cx(
|
||||
'fill-current',
|
||||
'h-4',
|
||||
'transition-transform',
|
||||
'w-4',
|
||||
showAdvanceOptions && '-rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{showAdvanceOptions && (
|
||||
<AdvancedOptions
|
||||
className="mt-2.5"
|
||||
depositExemptProposalCount={props.depositExemptProposalCount}
|
||||
minInstructionHoldupDays={props.minInstructionHoldupDays}
|
||||
programVersion={props.programVersion}
|
||||
onDepositExemptProposalCountChange={
|
||||
props.onDepositExemptProposalCountChange
|
||||
}
|
||||
onMinInstructionHoldupDaysChange={
|
||||
props.onMinInstructionHoldupDaysChange
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import type { PublicKey } from '@solana/web3.js';
|
||||
|
||||
import { SectionBlock } from '../SectionBlock';
|
||||
import { ValueBlock } from '../ValueBlock';
|
||||
import { Input } from '@hub/components/controls/Input';
|
||||
import { Textarea } from '@hub/components/controls/Textarea';
|
||||
import cx from '@hub/lib/cx';
|
||||
import { FormProps } from '@hub/types/FormProps';
|
||||
|
||||
interface Props
|
||||
extends FormProps<{
|
||||
proposalDescription: string;
|
||||
proposalTitle: string;
|
||||
}> {
|
||||
className?: string;
|
||||
governanceAddress: PublicKey;
|
||||
walletAddress: PublicKey;
|
||||
}
|
||||
|
||||
export function ProposalDetails(props: Props) {
|
||||
return (
|
||||
<SectionBlock className={cx(props.className, 'space-y-8')}>
|
||||
<ValueBlock
|
||||
title="Proposal Title"
|
||||
description="Consider using the suggested propsal title."
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full pr-12"
|
||||
value={props.proposalTitle}
|
||||
onChange={(e) => {
|
||||
const text = e.currentTarget.value;
|
||||
props.onProposalTitleChange?.(text);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
<ValueBlock
|
||||
title="Proposal Description"
|
||||
description="This will help voters understand more details about your proposed changes."
|
||||
>
|
||||
<Textarea
|
||||
className="h-28 w-full"
|
||||
value={props.proposalDescription}
|
||||
onChange={(e) => {
|
||||
props.onProposalDescriptionChange?.(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
</SectionBlock>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
import BuildingIcon from '@carbon/icons-react/lib/Building';
|
||||
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
|
||||
import UserMultipleIcon from '@carbon/icons-react/lib/UserMultiple';
|
||||
import WalletIcon from '@carbon/icons-react/lib/Wallet';
|
||||
import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { SectionBlock } from '../SectionBlock';
|
||||
import { SectionHeader } from '../SectionHeader';
|
||||
import { SummaryItem } from '../SummaryItem';
|
||||
import { CommunityRules, CouncilRules } from '../types';
|
||||
import { ValueBlock } from '../ValueBlock';
|
||||
import { getLabel } from '../VoteTippingSelector';
|
||||
|
||||
import { ButtonToggle } from '@hub/components/controls/ButtonToggle';
|
||||
import cx from '@hub/lib/cx';
|
||||
import { ntext } from '@hub/lib/ntext';
|
||||
import { FormProps } from '@hub/types/FormProps';
|
||||
|
||||
interface Props
|
||||
extends FormProps<{
|
||||
proposalVoteType: 'council' | 'community';
|
||||
}> {
|
||||
className?: string;
|
||||
currentCommunityRules: CommunityRules;
|
||||
currentCouncilRules: CouncilRules;
|
||||
currentBaseVoteDays: number;
|
||||
currentCoolOffHours: number;
|
||||
currentMinInstructionHoldupDays: number;
|
||||
}
|
||||
|
||||
export function ProposalVoteType(props: Props) {
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
|
||||
const rules =
|
||||
props.proposalVoteType === 'council' && props.currentCouncilRules
|
||||
? props.currentCouncilRules
|
||||
: props.currentCommunityRules;
|
||||
|
||||
const unrestrictedVotingHours = 24 * props.currentBaseVoteDays;
|
||||
const unrestrictedVotingDays = Math.floor(unrestrictedVotingHours / 24);
|
||||
const unrestrictedVotingRemainingHours =
|
||||
unrestrictedVotingHours - unrestrictedVotingDays * 24;
|
||||
|
||||
return (
|
||||
<SectionBlock className={props.className}>
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
icon={<WalletIcon />}
|
||||
text="Membership Voting"
|
||||
/>
|
||||
{!!props.currentCouncilRules &&
|
||||
props.currentCouncilRules.canVote &&
|
||||
props.currentCommunityRules.canVote ? (
|
||||
<ValueBlock
|
||||
title="Who should vote on this proposal?"
|
||||
description="Community or council?"
|
||||
>
|
||||
<ButtonToggle
|
||||
disableValueTrue={!props.currentCommunityRules.canVote}
|
||||
disableValueFalse={!props.currentCouncilRules}
|
||||
value={props.proposalVoteType === 'community'}
|
||||
valueTrueText="Community"
|
||||
valueFalseText="Council"
|
||||
onChange={(value) => {
|
||||
if (value === false && !!props.currentCouncilRules) {
|
||||
props.onProposalVoteTypeChange?.('council');
|
||||
} else {
|
||||
props.onProposalVoteTypeChange?.('community');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ValueBlock>
|
||||
) : props.proposalVoteType === 'council' ? (
|
||||
<div className="text-white text-lg">
|
||||
The proposal will be voted on by council members
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white text-lg">
|
||||
The proposal will be voted on by community members
|
||||
</div>
|
||||
)}
|
||||
{showRules && (
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center">
|
||||
{props.proposalVoteType === 'community' ? (
|
||||
<UserMultipleIcon className="h-4 fill-neutral-500 mr-3 w-4" />
|
||||
) : (
|
||||
<BuildingIcon className="h-4 fill-neutral-500 mr-3 w-4" />
|
||||
)}
|
||||
<div className="text-neutral-500">
|
||||
Current{' '}
|
||||
{props.proposalVoteType === 'community' ? 'Community' : 'Council'}{' '}
|
||||
Rules
|
||||
</div>
|
||||
</div>
|
||||
<div className="gap-x-4 gap-y-8 grid grid-cols-2 mt-8">
|
||||
<SummaryItem
|
||||
label="Unrestricted Voting Time"
|
||||
value={
|
||||
`${unrestrictedVotingDays} ${ntext(
|
||||
unrestrictedVotingDays,
|
||||
'day',
|
||||
)}` +
|
||||
(unrestrictedVotingRemainingHours
|
||||
? ` ${unrestrictedVotingRemainingHours} ${ntext(
|
||||
unrestrictedVotingRemainingHours,
|
||||
'hour',
|
||||
)}`
|
||||
: '')
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="Voting Cool-off Hours"
|
||||
value={`${props.currentCoolOffHours} ${ntext(
|
||||
props.currentCoolOffHours,
|
||||
'hour',
|
||||
)}`}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="Min Instruction Holdup Time"
|
||||
value={`${props.currentMinInstructionHoldupDays} ${ntext(
|
||||
props.currentMinInstructionHoldupDays,
|
||||
'day',
|
||||
)}`}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="Approval Quorum"
|
||||
value={`${rules.quorumPercent}%`}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="Vote Tipping"
|
||||
value={getLabel(rules.voteTipping)}
|
||||
/>
|
||||
{props.proposalVoteType === 'council' &&
|
||||
props.currentCommunityRules.canVeto && (
|
||||
<SummaryItem
|
||||
label="Community Veto Quorum"
|
||||
value={`${props.currentCommunityRules.vetoQuorumPercent}%`}
|
||||
/>
|
||||
)}
|
||||
{props.proposalVoteType === 'community' &&
|
||||
props.currentCouncilRules &&
|
||||
props.currentCouncilRules.canVeto && (
|
||||
<SummaryItem
|
||||
label="Council Veto Quorum"
|
||||
value={`${props.currentCommunityRules.vetoQuorumPercent}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center mt-10 text-sm text-neutral-500"
|
||||
onClick={() => setShowRules((cur) => !cur)}
|
||||
>
|
||||
{showRules ? 'Hide' : 'Show'} Voting Rules{' '}
|
||||
<ChevronDownIcon
|
||||
className={cx(
|
||||
'h-4',
|
||||
'ml-1.5',
|
||||
'transition-transform',
|
||||
'w-4',
|
||||
showRules && '-rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</SectionBlock>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SectionBlock(props: Props) {
|
||||
return (
|
||||
<section
|
||||
className={cx(props.className, 'p-8', 'rounded', 'dark:bg-black/40')}
|
||||
>
|
||||
{props.children}
|
||||
</section>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { cloneElement } from 'react';
|
||||
|
||||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function SectionHeader(props: Props) {
|
||||
return (
|
||||
<header
|
||||
className={cx(
|
||||
props.className,
|
||||
'flex',
|
||||
'items-center',
|
||||
'space-x-2',
|
||||
'text-neutral-500',
|
||||
)}
|
||||
>
|
||||
{cloneElement(props.icon, {
|
||||
className: cx(props.icon.props.className, 'fill-current', 'h-4', 'w-4'),
|
||||
})}
|
||||
<div className="text-xl font-medium">{props.text}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Input } from '@hub/components/controls/Input';
|
||||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
value: number;
|
||||
units: React.ReactNode;
|
||||
onChange?(value: number): void;
|
||||
}
|
||||
|
||||
export function SliderValue(props: Props) {
|
||||
const [value, setValue] = useState(String(props.value));
|
||||
|
||||
useEffect(() => {
|
||||
setValue(String(props.value));
|
||||
}, [props.value]);
|
||||
|
||||
return (
|
||||
<div className={cx('relative', props.className)}>
|
||||
<Input
|
||||
className="block w-full"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.currentTarget.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const text = e.currentTarget.value.replaceAll(
|
||||
/.*?(([0-9]*\.)?[0-9]+).*/g,
|
||||
'$1',
|
||||
);
|
||||
const parsed = parseFloat(text);
|
||||
const value = Number.isNaN(parsed) ? props.min : parsed;
|
||||
props.onChange?.(Math.max(props.min, value));
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cx(
|
||||
'absolute',
|
||||
'top-1/2',
|
||||
'right-4',
|
||||
'-translate-y-1/2',
|
||||
'text-center',
|
||||
'dark:text-neutral-500',
|
||||
)}
|
||||
>
|
||||
{props.units}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
import BotIcon from '@carbon/icons-react/lib/Bot';
|
||||
import type { PublicKey } from '@solana/web3.js';
|
||||
|
||||
import { ProposalDetails } from '../ProposalDetails';
|
||||
import { ProposalVoteType } from '../ProposalVoteType';
|
||||
import { CommunityRules, CouncilRules } from '../types';
|
||||
import { UpdatesList } from '../UpdatesList';
|
||||
import { WalletDescription } from '../WalletDescription';
|
||||
import { FormProps } from '@hub/types/FormProps';
|
||||
|
||||
interface Props
|
||||
extends FormProps<{
|
||||
proposalDescription: string;
|
||||
proposalTitle: string;
|
||||
proposalVoteType: 'council' | 'community';
|
||||
}> {
|
||||
className?: string;
|
||||
communityRules: CommunityRules;
|
||||
coolOffHours: number;
|
||||
councilRules: CouncilRules;
|
||||
currentCommunityRules: CommunityRules;
|
||||
currentCoolOffHours: number;
|
||||
currentCouncilRules: CouncilRules;
|
||||
currentDepositExemptProposalCount: number;
|
||||
currentBaseVoteDays: number;
|
||||
currentMinInstructionHoldupDays: number;
|
||||
depositExemptProposalCount: number;
|
||||
governanceAddress: PublicKey;
|
||||
baseVoteDays: number;
|
||||
minInstructionHoldupDays: number;
|
||||
walletAddress: PublicKey;
|
||||
}
|
||||
|
||||
export function Summary(props: Props) {
|
||||
return (
|
||||
<article className={props.className}>
|
||||
<WalletDescription
|
||||
className="mb-3"
|
||||
governanceAddress={props.governanceAddress}
|
||||
walletAddress={props.walletAddress}
|
||||
/>
|
||||
<h1 className="text-5xl font-medium m-0 mb-4 dark:text-white ">
|
||||
Your proposal is almost ready. Does everything look correct?
|
||||
</h1>
|
||||
<p className="m-0 mb-16 dark:text-neutral-300">
|
||||
Before submitting, ensure your description is correct and rules updates
|
||||
are accurate.
|
||||
</p>
|
||||
<ProposalDetails
|
||||
proposalDescription={props.proposalDescription}
|
||||
proposalTitle={props.proposalTitle}
|
||||
governanceAddress={props.governanceAddress}
|
||||
walletAddress={props.walletAddress}
|
||||
onProposalDescriptionChange={props.onProposalDescriptionChange}
|
||||
onProposalTitleChange={props.onProposalTitleChange}
|
||||
/>
|
||||
<ProposalVoteType
|
||||
className="mt-8"
|
||||
currentCommunityRules={props.currentCommunityRules}
|
||||
currentCouncilRules={props.currentCouncilRules}
|
||||
currentBaseVoteDays={props.currentBaseVoteDays}
|
||||
currentCoolOffHours={props.currentCoolOffHours}
|
||||
currentMinInstructionHoldupDays={props.currentMinInstructionHoldupDays}
|
||||
proposalVoteType={props.proposalVoteType}
|
||||
onProposalVoteTypeChange={props.onProposalVoteTypeChange}
|
||||
/>
|
||||
<div className="mt-14">
|
||||
<div className="text-lg font-bold dark:text-white">
|
||||
Proposed Rules Updates
|
||||
</div>
|
||||
<div className="flex items-center mt-3 dark:text-emerald-400">
|
||||
<BotIcon className="h-3 fill-current mr-1 w-4" />
|
||||
<div className="text-xs">This section is automatically generated</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpdatesList
|
||||
className="mt-4"
|
||||
communityRules={props.communityRules}
|
||||
coolOffHours={props.coolOffHours}
|
||||
councilRules={props.councilRules}
|
||||
currentCommunityRules={props.currentCommunityRules}
|
||||
currentCoolOffHours={props.currentCoolOffHours}
|
||||
currentCouncilRules={props.currentCouncilRules}
|
||||
currentDepositExemptProposalCount={
|
||||
props.currentDepositExemptProposalCount
|
||||
}
|
||||
currentBaseVoteDays={props.currentBaseVoteDays}
|
||||
currentMinInstructionHoldupDays={props.currentMinInstructionHoldupDays}
|
||||
depositExemptProposalCount={props.depositExemptProposalCount}
|
||||
baseVoteDays={props.baseVoteDays}
|
||||
minInstructionHoldupDays={props.minInstructionHoldupDays}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SummaryItem(props: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
props.className,
|
||||
'border-l',
|
||||
'pl-4',
|
||||
'dark:border-neutral-700',
|
||||
)}
|
||||
>
|
||||
<div className="text-sm dark:text-neutral-500">{props.label}</div>
|
||||
<div className="mt-2 text-xl dark:text-white">{props.value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,575 @@
|
|||
import BuildingIcon from '@carbon/icons-react/lib/Building';
|
||||
import ChemistryIcon from '@carbon/icons-react/lib/Chemistry';
|
||||
import TimeIcon from '@carbon/icons-react/lib/Time';
|
||||
import UserMultipleIcon from '@carbon/icons-react/lib/UserMultiple';
|
||||
import { PublicKey } from '@solana/web3.js';
|
||||
import { BigNumber } from 'bignumber.js';
|
||||
|
||||
import { SectionBlock } from '../SectionBlock';
|
||||
import { SectionHeader } from '../SectionHeader';
|
||||
import { SummaryItem } from '../SummaryItem';
|
||||
import { CommunityRules, CouncilRules } from '../types';
|
||||
import { getLabel } from '../VoteTippingSelector';
|
||||
import cx from '@hub/lib/cx';
|
||||
import { formatNumber } from '@hub/lib/formatNumber';
|
||||
import { ntext } from '@hub/lib/ntext';
|
||||
|
||||
function diff<T extends { [key: string]: unknown }>(existing: T, changed: T) {
|
||||
const diffs = {} as {
|
||||
[K in keyof T]: [T[K] | null, T[K] | null];
|
||||
};
|
||||
|
||||
for (const key of Object.keys(existing) as (keyof T)[]) {
|
||||
const existingValue = existing[key];
|
||||
const changedValue = changed[key];
|
||||
|
||||
if (
|
||||
existingValue instanceof PublicKey &&
|
||||
changedValue instanceof PublicKey
|
||||
) {
|
||||
continue;
|
||||
} else if (
|
||||
BigNumber.isBigNumber(existingValue) &&
|
||||
BigNumber.isBigNumber(changedValue)
|
||||
) {
|
||||
if (!existingValue.isEqualTo(changedValue)) {
|
||||
diffs[key] = [existingValue, changedValue];
|
||||
}
|
||||
} else {
|
||||
if (existingValue !== changedValue) {
|
||||
diffs[key] = [existingValue, changedValue];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
function unrestrictedVotingTimeText(days: number) {
|
||||
const hours = days * 24;
|
||||
const votingDays = Math.floor(hours / 24);
|
||||
const remainingHours = hours - votingDays * 24;
|
||||
|
||||
return (
|
||||
`${votingDays} ${ntext(votingDays, 'day')}` +
|
||||
(remainingHours
|
||||
? ` ${remainingHours} ${ntext(remainingHours, 'hour')}`
|
||||
: '')
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
communityRules: CommunityRules;
|
||||
coolOffHours: number;
|
||||
councilRules: CouncilRules;
|
||||
currentCommunityRules: CommunityRules;
|
||||
currentCoolOffHours: number;
|
||||
currentCouncilRules: CouncilRules;
|
||||
currentDepositExemptProposalCount: number;
|
||||
currentBaseVoteDays: number;
|
||||
currentMinInstructionHoldupDays: number;
|
||||
depositExemptProposalCount: number;
|
||||
baseVoteDays: number;
|
||||
minInstructionHoldupDays: number;
|
||||
}
|
||||
|
||||
export function UpdatesList(props: Props) {
|
||||
const currentVotingDuration = {
|
||||
coolOffHours: props.currentCoolOffHours,
|
||||
baseVoteDays: props.currentBaseVoteDays,
|
||||
};
|
||||
|
||||
const currentAdvancedSettings = {
|
||||
depositExemptProposalCount: props.currentDepositExemptProposalCount,
|
||||
minInstructionHoldupDays: props.currentMinInstructionHoldupDays,
|
||||
};
|
||||
|
||||
const newVotingDuration = {
|
||||
coolOffHours: props.coolOffHours,
|
||||
baseVoteDays: props.baseVoteDays,
|
||||
};
|
||||
|
||||
const newAdvancedSettings = {
|
||||
depositExemptProposalCount: props.depositExemptProposalCount,
|
||||
minInstructionHoldupDays: props.minInstructionHoldupDays,
|
||||
};
|
||||
|
||||
const votingDurationDiff = diff(currentVotingDuration, newVotingDuration);
|
||||
const communityDetailsDiff = diff(
|
||||
props.currentCommunityRules,
|
||||
props.communityRules,
|
||||
);
|
||||
const councilDetailsDiff = diff(
|
||||
(props.currentCouncilRules || {}) as NonNullable<CouncilRules>,
|
||||
(props.councilRules || {}) as NonNullable<CouncilRules>,
|
||||
);
|
||||
const advancedSettingsDiff = diff(
|
||||
currentAdvancedSettings,
|
||||
newAdvancedSettings,
|
||||
);
|
||||
|
||||
if (
|
||||
Object.keys(votingDurationDiff).length === 0 &&
|
||||
Object.keys(communityDetailsDiff).length === 0 &&
|
||||
Object.keys(councilDetailsDiff).length === 0 &&
|
||||
Object.keys(advancedSettingsDiff).length === 0
|
||||
) {
|
||||
return (
|
||||
<SectionBlock
|
||||
className={cx(
|
||||
props.className,
|
||||
'grid',
|
||||
'place-items-center',
|
||||
'w-full',
|
||||
'h-52',
|
||||
)}
|
||||
>
|
||||
<div className="text-lg dark:text-white">
|
||||
There are no proposed changes
|
||||
</div>
|
||||
</SectionBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionBlock className={cx('space-y-16', props.className)}>
|
||||
{!!Object.keys(votingDurationDiff).length && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
icon={<TimeIcon />}
|
||||
text="Voting Duration"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
{!!votingDurationDiff.baseVoteDays?.length && (
|
||||
<SummaryItem
|
||||
label="Unrestricted Voting Time"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
{votingDurationDiff.baseVoteDays[1] ? (
|
||||
<div>
|
||||
{unrestrictedVotingTimeText(
|
||||
votingDurationDiff.baseVoteDays[1],
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
{votingDurationDiff.baseVoteDays[0] ? (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{unrestrictedVotingTimeText(
|
||||
votingDurationDiff.baseVoteDays[0],
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!votingDurationDiff.coolOffHours?.length && (
|
||||
<SummaryItem
|
||||
label="Cool-Off Voting Time"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
{typeof votingDurationDiff.coolOffHours[1] === 'number' ? (
|
||||
<div>
|
||||
{votingDurationDiff.coolOffHours[1]}{' '}
|
||||
{ntext(votingDurationDiff.coolOffHours[1], 'hour')}
|
||||
</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
{typeof votingDurationDiff.coolOffHours[0] === 'number' ? (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{votingDurationDiff.coolOffHours[0]}{' '}
|
||||
{ntext(votingDurationDiff.coolOffHours[0], 'hour')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!Object.keys(communityDetailsDiff).length && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
icon={<UserMultipleIcon />}
|
||||
text="Community Details"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
{!!communityDetailsDiff.canCreateProposal?.length && (
|
||||
<SummaryItem
|
||||
label="Allow community members to create proposals"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
<div>
|
||||
{communityDetailsDiff.canCreateProposal[1] ? 'Yes' : 'No'}
|
||||
</div>
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{communityDetailsDiff.canCreateProposal[0] ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!communityDetailsDiff.votingPowerToCreateProposals?.length &&
|
||||
props.communityRules.canCreateProposal && (
|
||||
<SummaryItem
|
||||
label="Minimum amount of community tokens required to create a proposal"
|
||||
value={
|
||||
<div>
|
||||
{communityDetailsDiff.votingPowerToCreateProposals[1] ? (
|
||||
<div>
|
||||
{formatNumber(
|
||||
communityDetailsDiff
|
||||
.votingPowerToCreateProposals[1],
|
||||
undefined,
|
||||
{ maximumFractionDigits: 0 },
|
||||
)}{' '}
|
||||
{ntext(
|
||||
communityDetailsDiff.votingPowerToCreateProposals[1].toNumber(),
|
||||
'token',
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
{!props.currentCommunityRules.canCreateProposal ||
|
||||
!communityDetailsDiff.votingPowerToCreateProposals[0] ? (
|
||||
<div className="text-base text-neutral-500 line-through">
|
||||
Disabled
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-base text-neutral-500 line-through">
|
||||
{formatNumber(
|
||||
communityDetailsDiff
|
||||
.votingPowerToCreateProposals[0],
|
||||
undefined,
|
||||
{ maximumFractionDigits: 0 },
|
||||
)}{' '}
|
||||
{ntext(
|
||||
communityDetailsDiff.votingPowerToCreateProposals[0].toNumber(),
|
||||
'token',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!communityDetailsDiff.canVote?.length && (
|
||||
<SummaryItem
|
||||
label="Community Members Can Vote?"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
<div>{communityDetailsDiff.canVote[1] ? 'Yes' : 'No'}</div>
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{communityDetailsDiff.canVote[0] ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!communityDetailsDiff.quorumPercent?.length && (
|
||||
<SummaryItem
|
||||
label="Community Voting Quorum"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
{communityDetailsDiff.quorumPercent[1] ? (
|
||||
<div>{communityDetailsDiff.quorumPercent[1]}%</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
{communityDetailsDiff.quorumPercent[0] ? (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{communityDetailsDiff.quorumPercent[0]}%
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!communityDetailsDiff.voteTipping?.length && (
|
||||
<SummaryItem
|
||||
label="Community Vote Tipping"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
{communityDetailsDiff.voteTipping[1] ? (
|
||||
<div>{getLabel(communityDetailsDiff.voteTipping[1])}</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
{communityDetailsDiff.voteTipping[0] ? (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{getLabel(communityDetailsDiff.voteTipping[0])}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!communityDetailsDiff.canVeto?.length && (
|
||||
<SummaryItem
|
||||
label="Community Veto Power over Council Proposals?"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
<div>{communityDetailsDiff.canVeto[1] ? 'Yes' : 'No'}</div>
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{communityDetailsDiff.canVeto[0] ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{props.communityRules.canVeto &&
|
||||
!!communityDetailsDiff.vetoQuorumPercent?.length && (
|
||||
<SummaryItem
|
||||
label="Community Veto Voting Quorum"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
<div>
|
||||
{communityDetailsDiff.vetoQuorumPercent[1] || 0}%
|
||||
</div>
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{communityDetailsDiff.vetoQuorumPercent[0] || 0}%
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!Object.keys(councilDetailsDiff).length && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
icon={<BuildingIcon />}
|
||||
text="Council Details"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
{!!councilDetailsDiff.canCreateProposal && (
|
||||
<SummaryItem
|
||||
label="Allow council members to create proposals"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
<div>
|
||||
{councilDetailsDiff.canCreateProposal[1] ? 'Yes' : 'No'}
|
||||
</div>
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{councilDetailsDiff.canCreateProposal[0] ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!councilDetailsDiff.votingPowerToCreateProposals?.length &&
|
||||
props.councilRules?.canCreateProposal && (
|
||||
<SummaryItem
|
||||
label="Minimum amount of council tokens required to create a proposal"
|
||||
value={
|
||||
<div>
|
||||
{councilDetailsDiff.votingPowerToCreateProposals[1] ? (
|
||||
<div>
|
||||
{formatNumber(
|
||||
councilDetailsDiff.votingPowerToCreateProposals[1],
|
||||
undefined,
|
||||
{ maximumFractionDigits: 0 },
|
||||
)}{' '}
|
||||
{ntext(
|
||||
councilDetailsDiff.votingPowerToCreateProposals[1].toNumber(),
|
||||
'token',
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
{!props.currentCouncilRules?.canCreateProposal ||
|
||||
!councilDetailsDiff.votingPowerToCreateProposals[0] ? (
|
||||
<div className="text-base text-neutral-500 line-through">
|
||||
Disabled
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-base text-neutral-500 line-through">
|
||||
{formatNumber(
|
||||
councilDetailsDiff.votingPowerToCreateProposals[0],
|
||||
undefined,
|
||||
{ maximumFractionDigits: 0 },
|
||||
)}{' '}
|
||||
{ntext(
|
||||
councilDetailsDiff.votingPowerToCreateProposals[0].toNumber(),
|
||||
'token',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!councilDetailsDiff.canVote?.length && (
|
||||
<SummaryItem
|
||||
label="Council Members Can Vote?"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
<div>{councilDetailsDiff.canVote[1] ? 'Yes' : 'No'}</div>
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{councilDetailsDiff.canVote[0] ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!councilDetailsDiff.quorumPercent?.length && (
|
||||
<SummaryItem
|
||||
label="Council Voting Quorum"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
{councilDetailsDiff.quorumPercent[1] ? (
|
||||
<div>{councilDetailsDiff.quorumPercent[1]}%</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
{councilDetailsDiff.quorumPercent[0] ? (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{councilDetailsDiff.quorumPercent[0]}%
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!councilDetailsDiff.voteTipping?.length && (
|
||||
<SummaryItem
|
||||
label="Council Vote Tipping"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
{councilDetailsDiff.voteTipping[1] ? (
|
||||
<div>{getLabel(councilDetailsDiff.voteTipping[1])}</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
{councilDetailsDiff.voteTipping[0] ? (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{getLabel(councilDetailsDiff.voteTipping[0])}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!councilDetailsDiff.canVeto?.length && (
|
||||
<SummaryItem
|
||||
label="Council Veto Power over Community Proposals?"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
<div>{councilDetailsDiff.canVeto[1] ? 'Yes' : 'No'}</div>
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{councilDetailsDiff.canVeto[0] ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{props.councilRules?.canVeto &&
|
||||
!!councilDetailsDiff.vetoQuorumPercent?.length && (
|
||||
<SummaryItem
|
||||
label="Council Veto Voting Quorum"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
<div>{councilDetailsDiff.vetoQuorumPercent[1] || 0}%</div>
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{councilDetailsDiff.vetoQuorumPercent[0] || 0}%
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!Object.keys(advancedSettingsDiff).length && (
|
||||
<div className="space-y-8">
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
icon={<ChemistryIcon />}
|
||||
text="Advanced Options"
|
||||
/>
|
||||
{!!advancedSettingsDiff.depositExemptProposalCount?.length && (
|
||||
<SummaryItem
|
||||
label="The amount of proposals a member can create without a deposit."
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
<div>
|
||||
{advancedSettingsDiff.depositExemptProposalCount[1]}
|
||||
</div>
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{advancedSettingsDiff.depositExemptProposalCount[0]}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!advancedSettingsDiff.minInstructionHoldupDays?.length && (
|
||||
<SummaryItem
|
||||
label="Minimum Instruction Holdup Time"
|
||||
value={
|
||||
<div className="flex items-baseline">
|
||||
{advancedSettingsDiff.minInstructionHoldupDays[1] ? (
|
||||
<div>
|
||||
{advancedSettingsDiff.minInstructionHoldupDays[1]}{' '}
|
||||
{ntext(
|
||||
advancedSettingsDiff.minInstructionHoldupDays[1],
|
||||
'day',
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>Disabled</div>
|
||||
)}
|
||||
{advancedSettingsDiff.minInstructionHoldupDays[0] ? (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
{advancedSettingsDiff.minInstructionHoldupDays[0]}{' '}
|
||||
{ntext(
|
||||
advancedSettingsDiff.minInstructionHoldupDays[0],
|
||||
'day',
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-3 text-base text-neutral-500 line-through">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionBlock>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { ValueDescription } from '../ValueDescription';
|
||||
import { ValueLabel } from '../ValueLabel';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ValueBlock(props: Props) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<ValueLabel className="mb-1" text={props.title} />
|
||||
<ValueDescription className="mb-4" text={props.description} />
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
text: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ValueDescription(props: Props) {
|
||||
return (
|
||||
<div className={cx(props.className, 'text-sm', 'dark:text-neutral-300')}>
|
||||
{props.text}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function ValueLabel(props: Props) {
|
||||
return (
|
||||
<div className={cx(props.className, 'font-bold', 'dark:text-neutral-50')}>
|
||||
{props.text}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
import CheckmarkIcon from '@carbon/icons-react/lib/Checkmark';
|
||||
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import cx from '@hub/lib/cx';
|
||||
import { GovernanceVoteTipping } from '@hub/types/GovernanceVoteTipping';
|
||||
|
||||
export function getLabel(value: GovernanceVoteTipping): string {
|
||||
switch (value) {
|
||||
case GovernanceVoteTipping.Disabled:
|
||||
return 'Disabled';
|
||||
case GovernanceVoteTipping.Early:
|
||||
return 'Early';
|
||||
case GovernanceVoteTipping.Strict:
|
||||
return 'Strict';
|
||||
}
|
||||
}
|
||||
|
||||
function getDescription(value: GovernanceVoteTipping): string {
|
||||
switch (value) {
|
||||
case GovernanceVoteTipping.Disabled:
|
||||
return 'A proposal passes only when the total voting duration elapses';
|
||||
case GovernanceVoteTipping.Early:
|
||||
return 'A proposal passes when quorum is reached';
|
||||
case GovernanceVoteTipping.Strict:
|
||||
return 'A proposal passes as soon as it cannot mathematically be defeated';
|
||||
}
|
||||
}
|
||||
|
||||
const itemStyles = cx(
|
||||
'border',
|
||||
'cursor-pointer',
|
||||
'gap-x-4',
|
||||
'grid-cols-[80px,1fr,20px]',
|
||||
'grid',
|
||||
'h-14',
|
||||
'items-center',
|
||||
'px-4',
|
||||
'rounded-md',
|
||||
'text-left',
|
||||
'transition-colors',
|
||||
'dark:bg-neutral-800',
|
||||
'dark:border-neutral-700',
|
||||
'dark:hover:bg-neutral-700',
|
||||
);
|
||||
|
||||
const labelStyles = cx('font-700', 'dark:text-neutral-50');
|
||||
const descriptionStyles = cx('dark:text-neutral-400');
|
||||
const iconStyles = cx('fill-neutral-500', 'h-5', 'transition-transform', 'w-4');
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value: GovernanceVoteTipping;
|
||||
onChange?(value: GovernanceVoteTipping): void;
|
||||
}
|
||||
|
||||
export function VoteTippingSelector(props: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger.current) {
|
||||
setWidth(trigger.current.clientWidth);
|
||||
} else {
|
||||
setWidth(0);
|
||||
}
|
||||
}, [trigger, open]);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
|
||||
<div>
|
||||
<DropdownMenu.Trigger
|
||||
className={cx(itemStyles, props.className)}
|
||||
ref={trigger}
|
||||
>
|
||||
<div className={labelStyles}>{getLabel(props.value)}</div>
|
||||
<div className={descriptionStyles}>{getDescription(props.value)}</div>
|
||||
<ChevronDownIcon className={cx(iconStyles, open && '-rotate-180')} />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="space-y-0.5"
|
||||
sideOffset={2}
|
||||
style={{ width }}
|
||||
>
|
||||
{[
|
||||
GovernanceVoteTipping.Disabled,
|
||||
GovernanceVoteTipping.Early,
|
||||
GovernanceVoteTipping.Strict,
|
||||
]
|
||||
.filter((voteTippingType) => voteTippingType !== props.value)
|
||||
.map((voteTippingType) => (
|
||||
<DropdownMenu.Item
|
||||
className={cx(
|
||||
itemStyles,
|
||||
'w-full',
|
||||
'focus:outline-none',
|
||||
'dark:focus:bg-neutral-700',
|
||||
)}
|
||||
key={voteTippingType}
|
||||
onClick={() => props.onChange?.(voteTippingType)}
|
||||
>
|
||||
<div className={labelStyles}>{getLabel(voteTippingType)}</div>
|
||||
<div className={descriptionStyles}>
|
||||
{getDescription(voteTippingType)}
|
||||
</div>
|
||||
{voteTippingType === props.value && (
|
||||
<CheckmarkIcon className={iconStyles} />
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</div>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
import TimeIcon from '@carbon/icons-react/lib/Time';
|
||||
|
||||
import { SectionBlock } from '../SectionBlock';
|
||||
import { SectionHeader } from '../SectionHeader';
|
||||
import { SliderValue } from '../SliderValue';
|
||||
import { SummaryItem } from '../SummaryItem';
|
||||
import { ValueBlock } from '../ValueBlock';
|
||||
import { Slider } from '@hub/components/controls/Slider';
|
||||
import { formatNumber } from '@hub/lib/formatNumber';
|
||||
import { ntext } from '@hub/lib/ntext';
|
||||
import { FormProps } from '@hub/types/FormProps';
|
||||
|
||||
interface Props
|
||||
extends FormProps<{
|
||||
coolOffHours: number;
|
||||
maxVoteDays: number;
|
||||
}> {
|
||||
className?: string;
|
||||
programVersion: number;
|
||||
}
|
||||
|
||||
export function VotingDuration(props: Props) {
|
||||
const unrestrictedVotingHours = 24 * props.maxVoteDays - props.coolOffHours;
|
||||
const unrestrictedVotingDays = Math.floor(unrestrictedVotingHours / 24);
|
||||
const unrestrictedVotingRemainingHours =
|
||||
unrestrictedVotingHours - unrestrictedVotingDays * 24;
|
||||
|
||||
return (
|
||||
<SectionBlock className={props.className}>
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
icon={<TimeIcon />}
|
||||
text="Voting Duration"
|
||||
/>
|
||||
<ValueBlock
|
||||
description="The lifespan of your proposal, start to finish. This includes unrestricted and cool-off voting times."
|
||||
title="Total Voting Duration"
|
||||
>
|
||||
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
|
||||
<SliderValue
|
||||
min={1}
|
||||
max={7}
|
||||
value={props.maxVoteDays}
|
||||
units={ntext(props.maxVoteDays, 'Day')}
|
||||
onChange={props.onMaxVoteDaysChange}
|
||||
/>
|
||||
<Slider
|
||||
min={1}
|
||||
max={7}
|
||||
value={props.maxVoteDays}
|
||||
onChange={props.onMaxVoteDaysChange}
|
||||
/>
|
||||
</div>
|
||||
</ValueBlock>
|
||||
{props.programVersion >= 3 && (
|
||||
<ValueBlock
|
||||
className="mt-8"
|
||||
description={
|
||||
<>
|
||||
After an unrestricted voting time, cool-off voting time limits
|
||||
members to voting <span className="font-bold">No</span>,
|
||||
withdrawing their vote, or vetoing a proposal. A member cannot
|
||||
vote to approve a proposal during the cool-off time.
|
||||
</>
|
||||
}
|
||||
title="Cool-Off Voting Time"
|
||||
>
|
||||
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
|
||||
<SliderValue
|
||||
min={0}
|
||||
max={24}
|
||||
value={props.coolOffHours}
|
||||
units={ntext(props.coolOffHours, 'Hour')}
|
||||
onChange={props.onCoolOffHoursChange}
|
||||
/>
|
||||
<Slider
|
||||
min={0}
|
||||
max={24}
|
||||
trackColor="bg-orange-400"
|
||||
value={props.coolOffHours}
|
||||
onChange={props.onCoolOffHoursChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center mt-3 text-xs">
|
||||
<div className="dark:text-neutral-500">
|
||||
Unrestricted Voting Time:
|
||||
</div>
|
||||
<div className="ml-2 dark:text-neutral-50">
|
||||
<span className="font-bold">
|
||||
{formatNumber(unrestrictedVotingDays, undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>{' '}
|
||||
{ntext(unrestrictedVotingDays, 'day')}
|
||||
</div>
|
||||
{!!unrestrictedVotingRemainingHours && (
|
||||
<div className="ml-2 dark:text-neutral-50">
|
||||
<span className="font-bold">
|
||||
{formatNumber(unrestrictedVotingRemainingHours, undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>{' '}
|
||||
{ntext(unrestrictedVotingRemainingHours, 'hour')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ValueBlock>
|
||||
)}
|
||||
{props.programVersion >= 3 && (
|
||||
<div className="mt-12">
|
||||
<div className="font-bold text-neutral-500">Summary</div>
|
||||
<div className="grid grid-cols-3 gap-x-4 mt-4 pb-4">
|
||||
<SummaryItem
|
||||
label="Unrestricted Voting Time"
|
||||
value={
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
{formatNumber(unrestrictedVotingDays, undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{ntext(unrestrictedVotingDays, 'day')}
|
||||
</div>
|
||||
{!!unrestrictedVotingRemainingHours && (
|
||||
<div className="ml-3">
|
||||
{formatNumber(
|
||||
unrestrictedVotingRemainingHours,
|
||||
undefined,
|
||||
{
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
)}{' '}
|
||||
{ntext(unrestrictedVotingRemainingHours, 'hour')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="Cool-Off Voting Time"
|
||||
value={`${formatNumber(props.coolOffHours, undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})} ${ntext(props.coolOffHours, 'hour')}`}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="Total Voting Duration"
|
||||
value={`${formatNumber(props.maxVoteDays, undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})} ${ntext(props.maxVoteDays, 'day')}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionBlock>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import CopyIcon from '@carbon/icons-react/lib/Copy';
|
||||
import WalletIcon from '@carbon/icons-react/lib/Wallet';
|
||||
import type { PublicKey } from '@solana/web3.js';
|
||||
|
||||
import { getAccountName } from '@components/instructions/tools';
|
||||
import { CopyAddressButton } from '@hub/components/controls/CopyAddressButton';
|
||||
import { abbreviateAddress } from '@hub/lib/abbreviateAddress';
|
||||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
governanceAddress: PublicKey;
|
||||
walletAddress: PublicKey;
|
||||
}
|
||||
|
||||
export function WalletDescription(props: Props) {
|
||||
const name =
|
||||
getAccountName(props.walletAddress) ||
|
||||
getAccountName(props.governanceAddress);
|
||||
const address = abbreviateAddress(props.walletAddress);
|
||||
|
||||
return (
|
||||
<header className={cx('flex', 'items-center', props.className)}>
|
||||
<div className="flex items-center">
|
||||
<WalletIcon className="h-4 mr-2 w-4 dark:fill-white" />
|
||||
<div className="dark:text-white">{name || `Wallet ${address}`}</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-1 ml-2">
|
||||
<a
|
||||
className="text-xs dark:text-neutral-500 hover:underline"
|
||||
href={`https://explorer.solana.com/address/${props.governanceAddress}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{address}
|
||||
</a>
|
||||
<CopyAddressButton address={props.walletAddress} className="group ml-2">
|
||||
<CopyIcon className="h-3 transition-colors w-3 dark:fill-neutral-500 dark:group-hover:fill-neutral-300" />
|
||||
</CopyAddressButton>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import { BigNumber } from 'bignumber.js';
|
||||
|
||||
export const MAX_NUM = new BigNumber('18446744073709551615');
|
|
@ -0,0 +1,119 @@
|
|||
import {
|
||||
createSetGovernanceConfig,
|
||||
GovernanceConfig,
|
||||
VoteThresholdType,
|
||||
VoteTipping,
|
||||
} from '@solana/spl-governance';
|
||||
import type { PublicKey } from '@solana/web3.js';
|
||||
import BN from 'bn.js';
|
||||
|
||||
import { GovernanceVoteTipping } from '@hub/types/GovernanceVoteTipping';
|
||||
|
||||
import { MAX_NUM } from './constants';
|
||||
|
||||
import { Rules } from './types';
|
||||
|
||||
function hoursToSeconds(hours: number) {
|
||||
return hours * 60 * 60;
|
||||
}
|
||||
|
||||
function daysToSeconds(days: number) {
|
||||
return hoursToSeconds(days * 24);
|
||||
}
|
||||
|
||||
function convertVoteTipping(tipping: GovernanceVoteTipping): VoteTipping {
|
||||
switch (tipping) {
|
||||
case GovernanceVoteTipping.Disabled:
|
||||
return VoteTipping.Disabled;
|
||||
case GovernanceVoteTipping.Early:
|
||||
return VoteTipping.Early;
|
||||
case GovernanceVoteTipping.Strict:
|
||||
return VoteTipping.Strict;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTransaction(
|
||||
programId: PublicKey,
|
||||
programVersion: number,
|
||||
governance: PublicKey,
|
||||
rules: Rules,
|
||||
) {
|
||||
const communityRules = rules.communityTokenRules;
|
||||
const councilRules = rules.councilTokenRules;
|
||||
const minCommunityTokensToCreateProposal = new BN(
|
||||
(communityRules.canCreateProposal
|
||||
? communityRules.votingPowerToCreateProposals.shiftedBy(
|
||||
communityRules.tokenMintDecimals.toNumber(),
|
||||
)
|
||||
: MAX_NUM
|
||||
).toString(),
|
||||
);
|
||||
|
||||
const minCouncilTokensToCreateProposal = new BN(
|
||||
(councilRules && councilRules.canCreateProposal
|
||||
? councilRules.votingPowerToCreateProposals.shiftedBy(
|
||||
councilRules.tokenMintDecimals.toNumber(),
|
||||
)
|
||||
: MAX_NUM
|
||||
).toString(),
|
||||
);
|
||||
|
||||
const newConfig = new GovernanceConfig({
|
||||
minCommunityTokensToCreateProposal,
|
||||
minCouncilTokensToCreateProposal,
|
||||
communityVoteThreshold: communityRules.canVote
|
||||
? {
|
||||
type: VoteThresholdType.YesVotePercentage,
|
||||
value: communityRules.quorumPercent,
|
||||
}
|
||||
: {
|
||||
type: VoteThresholdType.Disabled,
|
||||
value: undefined,
|
||||
},
|
||||
minInstructionHoldUpTime: daysToSeconds(rules.minInstructionHoldupDays),
|
||||
maxVotingTime:
|
||||
daysToSeconds(rules.maxVoteDays) - hoursToSeconds(rules.coolOffHours),
|
||||
communityVoteTipping: convertVoteTipping(communityRules.voteTipping),
|
||||
councilVoteThreshold: councilRules?.canVote
|
||||
? {
|
||||
type: VoteThresholdType.YesVotePercentage,
|
||||
value: councilRules.quorumPercent,
|
||||
}
|
||||
: {
|
||||
type: VoteThresholdType.Disabled,
|
||||
value: undefined,
|
||||
},
|
||||
councilVetoVoteThreshold: councilRules?.canVeto
|
||||
? {
|
||||
type: VoteThresholdType.YesVotePercentage,
|
||||
value: councilRules.vetoQuorumPercent,
|
||||
}
|
||||
: {
|
||||
type: VoteThresholdType.Disabled,
|
||||
value: undefined,
|
||||
},
|
||||
communityVetoVoteThreshold: communityRules.canVeto
|
||||
? {
|
||||
type: VoteThresholdType.YesVotePercentage,
|
||||
value: communityRules.vetoQuorumPercent,
|
||||
}
|
||||
: {
|
||||
type: VoteThresholdType.Disabled,
|
||||
value: undefined,
|
||||
},
|
||||
councilVoteTipping: councilRules
|
||||
? convertVoteTipping(councilRules.voteTipping)
|
||||
: VoteTipping.Disabled,
|
||||
votingCoolOffTime: hoursToSeconds(rules.coolOffHours),
|
||||
depositExemptProposalCount: rules.depositExemptProposalCount,
|
||||
});
|
||||
|
||||
const instruction = createSetGovernanceConfig(
|
||||
programId,
|
||||
programVersion,
|
||||
governance,
|
||||
newConfig,
|
||||
);
|
||||
|
||||
return instruction;
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import * as IT from 'io-ts';
|
||||
import { gql } from 'urql';
|
||||
|
||||
import { BigNumber } from '@hub/types/decoders/BigNumber';
|
||||
import { GovernanceTokenType } from '@hub/types/decoders/GovernanceTokenType';
|
||||
import { GovernanceVoteTipping } from '@hub/types/decoders/GovernanceVoteTipping';
|
||||
import { PublicKey } from '@hub/types/decoders/PublicKey';
|
||||
|
||||
export const getGovernanceRules = gql`
|
||||
query($realmUrlId: String!, $governancePublicKey: PublicKey!) {
|
||||
me {
|
||||
publicKey
|
||||
}
|
||||
realmByUrlId(urlId: $realmUrlId) {
|
||||
programPublicKey
|
||||
publicKey
|
||||
governance(governance: $governancePublicKey) {
|
||||
communityTokenRules {
|
||||
canCreateProposal
|
||||
canVeto
|
||||
canVote
|
||||
quorumPercent
|
||||
tokenMintAddress
|
||||
tokenMintDecimals
|
||||
tokenType
|
||||
totalSupply
|
||||
vetoQuorumPercent
|
||||
voteTipping
|
||||
votingPowerToCreateProposals
|
||||
}
|
||||
coolOffHours
|
||||
councilTokenRules {
|
||||
canCreateProposal
|
||||
canVeto
|
||||
canVote
|
||||
quorumPercent
|
||||
tokenMintAddress
|
||||
tokenMintDecimals
|
||||
tokenType
|
||||
totalSupply
|
||||
vetoQuorumPercent
|
||||
voteTipping
|
||||
votingPowerToCreateProposals
|
||||
}
|
||||
depositExemptProposalCount
|
||||
governanceAddress
|
||||
maxVoteDays
|
||||
minInstructionHoldupDays
|
||||
version
|
||||
walletAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Rules = IT.type({
|
||||
canCreateProposal: IT.boolean,
|
||||
canVeto: IT.boolean,
|
||||
canVote: IT.boolean,
|
||||
quorumPercent: IT.number,
|
||||
tokenMintAddress: PublicKey,
|
||||
tokenMintDecimals: BigNumber,
|
||||
tokenType: GovernanceTokenType,
|
||||
totalSupply: BigNumber,
|
||||
vetoQuorumPercent: IT.number,
|
||||
voteTipping: GovernanceVoteTipping,
|
||||
votingPowerToCreateProposals: BigNumber,
|
||||
});
|
||||
|
||||
export const getGovernanceRulesResp = IT.type({
|
||||
me: IT.union([
|
||||
IT.null,
|
||||
IT.type({
|
||||
publicKey: PublicKey,
|
||||
}),
|
||||
]),
|
||||
realmByUrlId: IT.type({
|
||||
programPublicKey: PublicKey,
|
||||
publicKey: PublicKey,
|
||||
governance: IT.type({
|
||||
communityTokenRules: Rules,
|
||||
coolOffHours: IT.number,
|
||||
councilTokenRules: IT.union([IT.null, Rules]),
|
||||
depositExemptProposalCount: IT.number,
|
||||
governanceAddress: PublicKey,
|
||||
maxVoteDays: IT.number,
|
||||
minInstructionHoldupDays: IT.number,
|
||||
version: IT.number,
|
||||
walletAddress: PublicKey,
|
||||
}),
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,384 @@
|
|||
import CheckmarkIcon from '@carbon/icons-react/lib/Checkmark';
|
||||
import ChevronLeftIcon from '@carbon/icons-react/lib/ChevronLeft';
|
||||
import EditIcon from '@carbon/icons-react/lib/Edit';
|
||||
import { PublicKey } from '@solana/web3.js';
|
||||
import { BigNumber } from 'bignumber.js';
|
||||
import { hoursToSeconds, secondsToHours } from 'date-fns';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getAccountName } from '@components/instructions/tools';
|
||||
import { Primary, Secondary } from '@hub/components/controls/Button';
|
||||
import { Connect } from '@hub/components/GlobalHeader/User/Connect';
|
||||
import { ProposalCreationProgress } from '@hub/components/ProposalCreationProgress';
|
||||
import { useCluster, ClusterType } from '@hub/hooks/useCluster';
|
||||
import { useProposal } from '@hub/hooks/useProposal';
|
||||
import { useQuery } from '@hub/hooks/useQuery';
|
||||
import { useToast, ToastType } from '@hub/hooks/useToast';
|
||||
import cx from '@hub/lib/cx';
|
||||
import { GovernanceTokenType } from '@hub/types/GovernanceTokenType';
|
||||
import { GovernanceVoteTipping } from '@hub/types/GovernanceVoteTipping';
|
||||
import * as RE from '@hub/types/Result';
|
||||
|
||||
import { createTransaction } from './createTransaction';
|
||||
import { Form } from './Form';
|
||||
import * as gql from './gql';
|
||||
import { Summary } from './Summary';
|
||||
import { CommunityRules, CouncilRules } from './types';
|
||||
|
||||
enum Step {
|
||||
Form,
|
||||
Summary,
|
||||
}
|
||||
|
||||
function stepNum(step: Step): number {
|
||||
switch (step) {
|
||||
case Step.Form:
|
||||
return 1;
|
||||
case Step.Summary:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
function stepName(step: Step): string {
|
||||
switch (step) {
|
||||
case Step.Form:
|
||||
return 'Edit Wallet Rules';
|
||||
case Step.Summary:
|
||||
return 'Create Proposal';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
realmUrlId: string;
|
||||
governanceAddress: PublicKey;
|
||||
}
|
||||
|
||||
export function EditWalletRules(props: Props) {
|
||||
const [cluster] = useCluster();
|
||||
const { createProposal, progress } = useProposal();
|
||||
const { publish } = useToast();
|
||||
const [result] = useQuery(gql.getGovernanceRulesResp, {
|
||||
query: gql.getGovernanceRules,
|
||||
variables: {
|
||||
realmUrlId: props.realmUrlId,
|
||||
governancePublicKey: props.governanceAddress.toBase58(),
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(Step.Form);
|
||||
const [proposalVoteType, setProposalVoteType] = useState<
|
||||
'community' | 'council'
|
||||
>('community');
|
||||
const [proposalDescription, setProposalDescription] = useState('');
|
||||
const [proposalTitle, setProposalTitle] = useState('');
|
||||
|
||||
const [communityRules, setCommunityRules] = useState<CommunityRules>({
|
||||
canCreateProposal: true,
|
||||
canVeto: false,
|
||||
canVote: false,
|
||||
quorumPercent: 1,
|
||||
// this isn't a valid value, but it's just to satisfy the types for the
|
||||
// default initialized value
|
||||
tokenMintAddress: props.governanceAddress,
|
||||
tokenMintDecimals: new BigNumber(0),
|
||||
tokenType: GovernanceTokenType.Community,
|
||||
totalSupply: new BigNumber(1),
|
||||
vetoQuorumPercent: 100,
|
||||
voteTipping: GovernanceVoteTipping.Disabled,
|
||||
votingPowerToCreateProposals: new BigNumber(1),
|
||||
});
|
||||
|
||||
const [councilRules, setCouncilRules] = useState<CouncilRules>(null);
|
||||
const [coolOffHours, setCoolOffHours] = useState(0);
|
||||
const [depositExemptProposalCount, setDepositExemptProposalCount] = useState(
|
||||
0,
|
||||
);
|
||||
const [baseVoteDays, setBaseVoteDays] = useState(3);
|
||||
const [maxVoteDays, setMaxVoteDays] = useState(3);
|
||||
const [minInstructionHoldupDays, setMinInstructionHoldupDays] = useState(0);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (RE.isOk(result)) {
|
||||
const data = result.data.realmByUrlId.governance;
|
||||
|
||||
setCommunityRules(data.communityTokenRules);
|
||||
setCoolOffHours(data.coolOffHours);
|
||||
setCouncilRules(data.councilTokenRules);
|
||||
setDepositExemptProposalCount(data.depositExemptProposalCount);
|
||||
|
||||
// maxVotingDays is actually misnamed on-chain. It should be `baseVotingDays`
|
||||
const baseVotingSeconds = hoursToSeconds(24 * data.maxVoteDays);
|
||||
const coolOffSeconds = hoursToSeconds(data.coolOffHours);
|
||||
const maxVotingSeconds = baseVotingSeconds + coolOffSeconds;
|
||||
|
||||
setBaseVoteDays(data.maxVoteDays);
|
||||
setMaxVoteDays(maxVotingSeconds / 60 / 60 / 24);
|
||||
setMinInstructionHoldupDays(data.minInstructionHoldupDays);
|
||||
|
||||
if (!data.councilTokenRules) {
|
||||
setProposalVoteType('community');
|
||||
} else if (!data.communityTokenRules.canVote) {
|
||||
setProposalVoteType('council');
|
||||
}
|
||||
|
||||
const walletName =
|
||||
getAccountName(data.walletAddress) ||
|
||||
getAccountName(data.governanceAddress) ||
|
||||
data.walletAddress.toBase58();
|
||||
|
||||
const title = `Update Wallet Rules for “${walletName}”`;
|
||||
|
||||
setProposalTitle(title);
|
||||
}
|
||||
}, [result._tag]);
|
||||
|
||||
return pipe(
|
||||
result,
|
||||
|
||||
RE.match(
|
||||
() => <div />,
|
||||
() => <div />,
|
||||
({ me, realmByUrlId: { governance, programPublicKey, publicKey } }) => {
|
||||
const walletName =
|
||||
getAccountName(governance.walletAddress) ||
|
||||
getAccountName(governance.governanceAddress) ||
|
||||
governance.walletAddress.toBase58();
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<div className={cx(props.className, 'dark:bg-neutral-900')}>
|
||||
<Head>
|
||||
<title>Edit Wallet Rules - {walletName}</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`Edit Wallet Rules - ${governance.walletAddress.toBase58()}`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
<div className="w-full max-w-3xl pt-14 mx-auto grid place-items-center">
|
||||
<div className="my-16 py-8 px-16 dark:bg-black/40 rounded flex flex-col items-center">
|
||||
<div className="text-white mb-2 text-center">
|
||||
Please sign in to edit wallet rules
|
||||
<br />
|
||||
for "{walletName}"
|
||||
</div>
|
||||
<Connect />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(props.className, 'dark:bg-neutral-900')}>
|
||||
<ProposalCreationProgress progress={progress} />
|
||||
<div className="w-full max-w-3xl pt-14 mx-auto">
|
||||
<Head>
|
||||
<title>Edit Wallet Rules - {walletName}</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`Edit Wallet Rules - ${governance.walletAddress.toBase58()}`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
<div className="flex items-center mt-4">
|
||||
<div className="text-sm dark:text-neutral-500">
|
||||
Step {stepNum(step)} of 2
|
||||
</div>
|
||||
<div className="text-sm dark:text-white ml-2">
|
||||
{stepName(step)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-16">
|
||||
{step === Step.Form && (
|
||||
<>
|
||||
<Form
|
||||
className="mb-16"
|
||||
communityRules={communityRules}
|
||||
coolOffHours={coolOffHours}
|
||||
councilRules={councilRules}
|
||||
currentCommunityRules={governance.communityTokenRules}
|
||||
currentCouncilRules={governance.councilTokenRules}
|
||||
depositExemptProposalCount={depositExemptProposalCount}
|
||||
governanceAddress={governance.governanceAddress}
|
||||
maxVoteDays={maxVoteDays}
|
||||
minInstructionHoldupDays={minInstructionHoldupDays}
|
||||
programVersion={governance.version}
|
||||
walletAddress={governance.walletAddress}
|
||||
onCommunityRulesChange={setCommunityRules}
|
||||
onCoolOffHoursChange={(coolOffHours) => {
|
||||
setCoolOffHours(coolOffHours);
|
||||
const maxVotingSeconds = hoursToSeconds(
|
||||
maxVoteDays * 24,
|
||||
);
|
||||
const coolOffSeconds = hoursToSeconds(coolOffHours);
|
||||
const baseVotingSeconds =
|
||||
maxVotingSeconds - coolOffSeconds;
|
||||
setBaseVoteDays(secondsToHours(baseVotingSeconds) / 24);
|
||||
}}
|
||||
onCouncilRulesChange={setCouncilRules}
|
||||
onDepositExemptProposalCountChange={
|
||||
setDepositExemptProposalCount
|
||||
}
|
||||
onMaxVoteDaysChange={(votingDays) => {
|
||||
setMaxVoteDays(votingDays);
|
||||
const maxVotingSeconds = hoursToSeconds(
|
||||
24 * votingDays,
|
||||
);
|
||||
const coolOffSeconds = hoursToSeconds(coolOffHours);
|
||||
const baseVotingSeconds =
|
||||
maxVotingSeconds - coolOffSeconds;
|
||||
setBaseVoteDays(secondsToHours(baseVotingSeconds) / 24);
|
||||
}}
|
||||
onMinInstructionHoldupDaysChange={
|
||||
setMinInstructionHoldupDays
|
||||
}
|
||||
/>
|
||||
<footer className="flex items-center justify-between">
|
||||
<button
|
||||
className="flex items-center text-sm text-neutral-500"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 fill-current w-4" />
|
||||
Go Back
|
||||
</button>
|
||||
<Secondary
|
||||
className="h-14 w-44"
|
||||
onClick={() => setStep(Step.Summary)}
|
||||
>
|
||||
Continue
|
||||
</Secondary>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
{step === Step.Summary && (
|
||||
<>
|
||||
<Summary
|
||||
className="mb-16"
|
||||
communityRules={communityRules}
|
||||
coolOffHours={coolOffHours}
|
||||
councilRules={councilRules}
|
||||
currentCommunityRules={governance.communityTokenRules}
|
||||
currentCoolOffHours={governance.coolOffHours}
|
||||
currentCouncilRules={governance.councilTokenRules}
|
||||
currentDepositExemptProposalCount={
|
||||
governance.depositExemptProposalCount
|
||||
}
|
||||
currentBaseVoteDays={governance.maxVoteDays}
|
||||
currentMinInstructionHoldupDays={
|
||||
governance.minInstructionHoldupDays
|
||||
}
|
||||
depositExemptProposalCount={depositExemptProposalCount}
|
||||
governanceAddress={governance.governanceAddress}
|
||||
baseVoteDays={baseVoteDays}
|
||||
minInstructionHoldupDays={minInstructionHoldupDays}
|
||||
proposalDescription={proposalDescription}
|
||||
proposalTitle={proposalTitle}
|
||||
proposalVoteType={proposalVoteType}
|
||||
walletAddress={governance.walletAddress}
|
||||
onProposalDescriptionChange={setProposalDescription}
|
||||
onProposalTitleChange={setProposalTitle}
|
||||
onProposalVoteTypeChange={setProposalVoteType}
|
||||
/>
|
||||
<footer className="flex items-center justify-end">
|
||||
<button
|
||||
className="flex items-center text-sm text-neutral-500"
|
||||
onClick={() => setStep(Step.Form)}
|
||||
>
|
||||
<EditIcon className="h-4 fill-current mr-1 w-4" />
|
||||
Edit Rules
|
||||
</button>
|
||||
<Primary
|
||||
className="ml-16 h-14 w-44"
|
||||
pending={submitting}
|
||||
onClick={async () => {
|
||||
setSubmitting(true);
|
||||
|
||||
const transaction = createTransaction(
|
||||
programPublicKey,
|
||||
governance.version,
|
||||
governance.governanceAddress,
|
||||
{
|
||||
coolOffHours,
|
||||
depositExemptProposalCount,
|
||||
maxVoteDays,
|
||||
minInstructionHoldupDays,
|
||||
communityTokenRules: communityRules,
|
||||
councilTokenRules: councilRules,
|
||||
governanceAddress: governance.governanceAddress,
|
||||
version: governance.version,
|
||||
walletAddress: governance.walletAddress,
|
||||
},
|
||||
);
|
||||
|
||||
const governingTokenMintPublicKey =
|
||||
proposalVoteType === 'council' &&
|
||||
governance.councilTokenRules
|
||||
? governance.councilTokenRules.tokenMintAddress
|
||||
: governance.communityTokenRules.tokenMintAddress;
|
||||
|
||||
try {
|
||||
const proposalAddress = await createProposal({
|
||||
governingTokenMintPublicKey,
|
||||
programPublicKey,
|
||||
proposalDescription,
|
||||
proposalTitle,
|
||||
governancePublicKey: governance.governanceAddress,
|
||||
instructions: [transaction],
|
||||
isDraft: false,
|
||||
realmPublicKey: publicKey,
|
||||
councilTokenMintPublicKey:
|
||||
governance.councilTokenRules
|
||||
?.tokenMintAddress || undefined,
|
||||
communityTokenMintPublicKey:
|
||||
governance.communityTokenRules.tokenMintAddress,
|
||||
});
|
||||
|
||||
if (proposalAddress) {
|
||||
router.push(
|
||||
`/dao/${
|
||||
props.realmUrlId
|
||||
}/proposal/${proposalAddress.toBase58()}` +
|
||||
(cluster.type === ClusterType.Devnet
|
||||
? '?cluster=devnet'
|
||||
: ''),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
publish({
|
||||
type: ToastType.Error,
|
||||
title: 'Could not create proposal.',
|
||||
message: String(e),
|
||||
});
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
}}
|
||||
>
|
||||
<CheckmarkIcon className="h-4 fill-current mr-1 w-4" />
|
||||
Create Proposal
|
||||
</Primary>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { TypeOf } from 'io-ts';
|
||||
|
||||
import * as gql from './gql';
|
||||
|
||||
export type Rules = TypeOf<
|
||||
typeof gql.getGovernanceRulesResp
|
||||
>['realmByUrlId']['governance'];
|
||||
export type CommunityRules = Rules['communityTokenRules'];
|
||||
export type CouncilRules = Rules['councilTokenRules'];
|
|
@ -35,6 +35,8 @@ export function Links(props: Props) {
|
|||
<Link passHref href={link.href} key={i}>
|
||||
<NavigationMenu.Link
|
||||
className={cx(
|
||||
'dark:text-neutral-400',
|
||||
'dark:hover:text-neutral-200',
|
||||
'block',
|
||||
'group',
|
||||
'py-2',
|
||||
|
|
|
@ -49,6 +49,7 @@ export function LinksDropdown(props: Props) {
|
|||
'text-sm',
|
||||
'transition-colors',
|
||||
'group-hover:text-neutral-900',
|
||||
'dark:group-hover:text-neutral-200',
|
||||
)}
|
||||
>
|
||||
<Select.Value asChild>
|
||||
|
@ -63,6 +64,7 @@ export function LinksDropdown(props: Props) {
|
|||
'transition-colors',
|
||||
'w-3',
|
||||
'group-hover:fill-neutral-900',
|
||||
'dark:group-hover:fill-neutral-200',
|
||||
)}
|
||||
/>
|
||||
</Select.Icon>
|
||||
|
@ -71,6 +73,7 @@ export function LinksDropdown(props: Props) {
|
|||
<Select.Portal>
|
||||
<Select.Content
|
||||
className={cx(
|
||||
'dark:bg-neutral-900',
|
||||
'bg-white',
|
||||
'rounded',
|
||||
'overflow-hidden',
|
||||
|
@ -96,6 +99,9 @@ export function LinksDropdown(props: Props) {
|
|||
'text-neutral-900',
|
||||
'hover:bg-neutral-200',
|
||||
'focus:bg-neutral-200',
|
||||
'dark:text-neutral-400',
|
||||
'dark:hover:bg-neutral-700',
|
||||
'dark:focus:bg-neutral-700',
|
||||
)}
|
||||
key={i}
|
||||
>
|
||||
|
|
|
@ -24,6 +24,7 @@ export function MinimalHeader(props: Props) {
|
|||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'dark:bg-neutral-800',
|
||||
)}
|
||||
style={{
|
||||
paddingRight: 'var(--removed-body-scroll-bar-size)',
|
||||
|
@ -43,7 +44,10 @@ export function MinimalHeader(props: Props) {
|
|||
)}
|
||||
>
|
||||
<div className={cx('flex', 'items-center')}>
|
||||
<Logo compressed={!showExpandedUserDropdown} />
|
||||
<Logo
|
||||
className="text-[#201F27] dark:text-neutral-50"
|
||||
compressed={!showExpandedUserDropdown}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<User compressed={!showExpandedUserDropdown} />
|
||||
|
|
|
@ -52,7 +52,6 @@ export function Connect(props: Props) {
|
|||
<NavigationMenu.Item>
|
||||
<button
|
||||
className={cx(
|
||||
props.className,
|
||||
'cursor-pointer',
|
||||
'flex',
|
||||
'items-center',
|
||||
|
@ -66,6 +65,11 @@ export function Connect(props: Props) {
|
|||
'transition-colors',
|
||||
'active:bg-black/20',
|
||||
'hover:bg-black/10',
|
||||
'dark:text-neutral-400',
|
||||
'dark:hover:text-neutral-200',
|
||||
'dark:active:bg-neutral-800',
|
||||
'dark:hover:bg-neutral-700',
|
||||
props.className,
|
||||
)}
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
|
|
@ -16,8 +16,6 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||
import { REALMS_PUBLIC_KEY, themeVariables } from './Notifications.constants';
|
||||
import { solanaWalletToDialectWallet } from './solanaWalletToDialectWallet';
|
||||
|
||||
type ThemeType = 'light' | 'dark' | undefined;
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
|
|
@ -4,13 +4,15 @@ import {
|
|||
} from '@dialectlabs/react-ui';
|
||||
import { PublicKey } from '@solana/web3.js';
|
||||
|
||||
import cx from '@hub/lib/cx';
|
||||
|
||||
export const REALMS_PUBLIC_KEY = new PublicKey(
|
||||
'BUxZD6aECR5B5MopyvvYqJxwSKDBhx2jSSo1U32en6mj',
|
||||
);
|
||||
|
||||
export const themeVariables: IncomingThemeVariables = {
|
||||
dark: {
|
||||
bellButton: `${defaultVariables.dark.bellButton} bg-transparent !shadow-none text-neutral-700 h-10 rounded-full w-10 hover:bg-bkg-3`,
|
||||
bellButton: `${defaultVariables.dark.bellButton} bg-transparent !shadow-none text-neutral-300 h-10 rounded-full w-10 hover:bg-bkg-3`,
|
||||
iconButton: `${defaultVariables.dark.iconButton} hover:opacity-100 bg-transparent`,
|
||||
adornmentButton: `${defaultVariables.dark.adornmentButton} bg-sky-500 hover:!bg-sky-400 active:bg-sky-500 rounded transition-colors`,
|
||||
buttonLoading: `${defaultVariables.dark.buttonLoading} rounded-full min-h-[40px]`,
|
||||
|
@ -32,7 +34,21 @@ export const themeVariables: IncomingThemeVariables = {
|
|||
secondaryDangerButton: `${defaultVariables.dark.secondaryDangerButton} rounded-full`,
|
||||
},
|
||||
light: {
|
||||
bellButton: `${defaultVariables.light.bellButton} w-10 h-10 border-none bg-transparent shadow-none text-neutral-600 active:bg-neutral-300 hover:bg-neutral-200`,
|
||||
bellButton: cx(
|
||||
defaultVariables.light.bellButton,
|
||||
'bg-transparent',
|
||||
'border-none',
|
||||
'h-10',
|
||||
'shadow-none',
|
||||
'text-neutral-600',
|
||||
'w-10',
|
||||
'active:bg-neutral-300',
|
||||
'hover:bg-neutral-200',
|
||||
'dark:text-neutral-400',
|
||||
'dark:hover:text-neutral-200',
|
||||
'dark:active:bg-neutral-600',
|
||||
'dark:hover:bg-neutral-700',
|
||||
),
|
||||
iconButton: `${defaultVariables.light.iconButton} hover:opacity-100 bg-transparent`,
|
||||
buttonLoading: `${defaultVariables.light.buttonLoading} rounded-full min-h-[40px]`,
|
||||
adornmentButton: `${defaultVariables.light.adornmentButton} bg-sky-500 hover:!bg-sky-400 active:bg-sky-500 rounded transition-colors`,
|
||||
|
@ -44,7 +60,11 @@ export const themeVariables: IncomingThemeVariables = {
|
|||
toggleBackgroundActive: 'bg-sky-500',
|
||||
},
|
||||
textStyles: {
|
||||
input: `${defaultVariables.light.textStyles.input} text-neutral-900 placeholder:text-fgd-3`,
|
||||
input: cx(
|
||||
defaultVariables.light.textStyles.input,
|
||||
'text-neutral-900',
|
||||
'placeholder:text-fgd-3',
|
||||
),
|
||||
body: `${defaultVariables.light.textStyles.body} text-neutral-900`,
|
||||
small: `${defaultVariables.light.textStyles.small} text-neutral-900`,
|
||||
xsmall: `${defaultVariables.light.textStyles.xsmall} text-neutral-900`,
|
||||
|
|
|
@ -14,7 +14,7 @@ export function solanaWalletToDialectWallet(
|
|||
}
|
||||
|
||||
return {
|
||||
publicKey: wallet.publicKey!,
|
||||
publicKey: wallet.publicKey,
|
||||
signMessage: wallet.signMessage,
|
||||
signTransaction: wallet.signTransaction,
|
||||
signAllTransactions: wallet.signAllTransactions,
|
||||
|
|
|
@ -25,6 +25,12 @@ export function DropdownButton(props: Props) {
|
|||
'w-full',
|
||||
'active:bg-neutral-300',
|
||||
'hover:bg-neutral-200',
|
||||
'dark:fill-neutral-400',
|
||||
'dark:text-neutral-400',
|
||||
'dark:active:bg-neutral-600',
|
||||
'dark:hover:bg-neutral-700',
|
||||
'dark:hover:text-neutral-200',
|
||||
'dark:hover:fill-neutral-200',
|
||||
)}
|
||||
/>
|
||||
</NavigationMenu.Item>
|
||||
|
|
|
@ -46,6 +46,10 @@ export function UserDropdown(props: Props) {
|
|||
'transition-colors',
|
||||
'active:bg-neutral-300',
|
||||
'hover:bg-neutral-200',
|
||||
'dark:text-neutral-400',
|
||||
'dark:hover:bg-neutral-700',
|
||||
'dark:active:bg-neutral-600',
|
||||
'dark:hover:text-neutral-200',
|
||||
!props.compressed && 'w-48',
|
||||
)}
|
||||
>
|
||||
|
@ -55,7 +59,7 @@ export function UserDropdown(props: Props) {
|
|||
<div className="truncate flex-shrink">{username}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon className="h-4 w-4 fill-neutral-900 flex-shrink-0" />
|
||||
<ChevronDownIcon className="h-4 w-4 fill-neutral-900 flex-shrink-0 dark:fill-neutral-400" />
|
||||
</NavigationMenu.Trigger>
|
||||
<NavigationMenu.Content
|
||||
className={cx(
|
||||
|
@ -65,6 +69,7 @@ export function UserDropdown(props: Props) {
|
|||
'overflow-hidden',
|
||||
'rounded',
|
||||
'w-48',
|
||||
'dark:bg-neutral-900',
|
||||
!!props.compressed && 'right-3',
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -34,6 +34,7 @@ export function GlobalHeader(props: Props) {
|
|||
<NavigationMenu.Root
|
||||
className={cx(
|
||||
props.className,
|
||||
'dark:bg-neutral-800',
|
||||
'bg-white',
|
||||
'flex',
|
||||
'items-center',
|
||||
|
@ -57,7 +58,10 @@ export function GlobalHeader(props: Props) {
|
|||
)}
|
||||
>
|
||||
<div className={cx('flex', 'items-center')}>
|
||||
<Logo compressed={!showExpandedUserDropdown} />
|
||||
<Logo
|
||||
className="text-[#201F27] dark:text-neutral-50"
|
||||
compressed={!showExpandedUserDropdown}
|
||||
/>
|
||||
{showDesktopRealmSelector && (
|
||||
<NavigationMenu.Item asChild>
|
||||
<RealmSearchNavigation className="ml-4" />
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import * as Separator from '@radix-ui/react-separator';
|
||||
import type { PublicKey } from '@solana/web3.js';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
import React from 'react';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import ChevronRightIcon from '@carbon/icons-react/lib/ChevronRight';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { abbreviateNumber } from '@hub/lib/abbreviateNumber';
|
||||
|
@ -23,7 +22,6 @@ interface BountyData {
|
|||
export function Bounties(props: Props) {
|
||||
const [bounties, setBounties] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import * as Separator from '@radix-ui/react-separator';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
import React from 'react';
|
||||
|
||||
|
|
|
@ -41,6 +41,7 @@ export function NewPostEditor(props: Props) {
|
|||
const [, createPost] = useMutation(gql.createPostResp, gql.createPost);
|
||||
const [error, setError] = useState<CombinedError | null>(null);
|
||||
const [realm, setRealm] = useState(props.realm);
|
||||
// eslint-disable-next-line
|
||||
const [crosspostTo, setCrosspostTo] = useState<
|
||||
{ name: string; publicKey: PublicKey; iconUrl?: null | string }[]
|
||||
>([]);
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
import * as Progress from '@radix-ui/react-progress';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
CreationProgress,
|
||||
CreationProgressState,
|
||||
} from '@hub/hooks/useProposalCreationProgress';
|
||||
import cx from '@hub/lib/cx';
|
||||
|
||||
function Modal(props: { className?: string; children?: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
props.className,
|
||||
'fixed',
|
||||
'top-0',
|
||||
'left-0',
|
||||
'right-0',
|
||||
'bottom-0',
|
||||
'z-50',
|
||||
'bg-black/40',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'-translate-x-1/2',
|
||||
'-translate-y-1/2',
|
||||
'absolute',
|
||||
'bg-neutral-800',
|
||||
'left-1/2',
|
||||
'max-w-3xl',
|
||||
'p-8',
|
||||
'rounded',
|
||||
'shadow-2xl',
|
||||
'top-1/2',
|
||||
'w-full',
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
progress: CreationProgress;
|
||||
}
|
||||
|
||||
export function ProposalCreationProgress(props: Props) {
|
||||
if (props.progress.state === CreationProgressState.Processing) {
|
||||
const progressValue =
|
||||
((props.progress.transactionsCompleted + 1) /
|
||||
props.progress.totalTransactions) *
|
||||
100;
|
||||
|
||||
return (
|
||||
<Modal className={props.className}>
|
||||
<div className="text-white">Creating proposal</div>
|
||||
<div className="mt-4">
|
||||
<Progress.Root
|
||||
className="h-10 rounded overflow-hidden w-full bg-neutral-200 relative"
|
||||
value={progressValue}
|
||||
>
|
||||
<Progress.Indicator
|
||||
className={cx(
|
||||
'absolute',
|
||||
'animate-move-stripes',
|
||||
'duration-700',
|
||||
'top-0',
|
||||
'bottom-0',
|
||||
'transition-all',
|
||||
'w-full',
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
'repeating-linear-gradient(-67.5deg, #bae6fd, #bae6fd 20px, #7dd3fc 20px, #7dd3fc 40px)',
|
||||
right: `${100 - progressValue}%`,
|
||||
}}
|
||||
/>
|
||||
</Progress.Root>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-neutral-500">
|
||||
Processing transaction{' '}
|
||||
<span className="font-bold">
|
||||
{props.progress.transactionsCompleted + 1}
|
||||
</span>{' '}
|
||||
of{' '}
|
||||
<span className="font-bold">{props.progress.totalTransactions}</span>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
|
@ -16,6 +16,7 @@ export function RealmIcon(props: Props) {
|
|||
'rounded-full',
|
||||
'border',
|
||||
'border-neutral-400',
|
||||
'dark:border-neutral-600',
|
||||
props.className,
|
||||
)}
|
||||
src={props.iconUrl}
|
||||
|
@ -28,6 +29,7 @@ export function RealmIcon(props: Props) {
|
|||
'rounded-full',
|
||||
'border',
|
||||
'border-neutral-400',
|
||||
'dark:border-neutral-600',
|
||||
props.className,
|
||||
)}
|
||||
src={ecosystemIcon.src}
|
||||
|
|
|
@ -129,8 +129,12 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
|
|||
'placeholder:transition-colors',
|
||||
'focus:placeholder:text-neutral-300',
|
||||
'focus:outline-none',
|
||||
'dark:bg-neutral-900',
|
||||
'dark:border-neutral-700',
|
||||
'dark:placeholder:text-neutral-400',
|
||||
'dark:focus:placeholder:text-neutral-200',
|
||||
)}
|
||||
placeholder="Communities"
|
||||
placeholder="Organizations"
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.currentTarget.value)}
|
||||
|
@ -145,10 +149,12 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
|
|||
'left-4',
|
||||
'top-1/2',
|
||||
'w-4',
|
||||
'dark:fill-neutral-400',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
className={cx(
|
||||
'dark:text-neutral-400',
|
||||
'-translate-y-1/2',
|
||||
'absolute',
|
||||
'focus:opacity-100',
|
||||
|
@ -186,6 +192,7 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
|
|||
align="start"
|
||||
sideOffset={4}
|
||||
className={cx(
|
||||
'dark:bg-neutral-900',
|
||||
'drop-shadow-lg',
|
||||
'bg-white',
|
||||
'overflow-hidden',
|
||||
|
@ -215,11 +222,13 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
|
|||
'gap-x-2',
|
||||
'grid-cols-[24px,1fr]',
|
||||
'grid',
|
||||
'group',
|
||||
'items-center',
|
||||
'p-2',
|
||||
'transition-colors',
|
||||
'w-full',
|
||||
'hover:bg-neutral-200',
|
||||
'dark:hover:bg-neutral-700',
|
||||
)}
|
||||
onClick={() => {
|
||||
setText('');
|
||||
|
@ -231,7 +240,15 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
|
|||
iconUrl={option.iconUrl}
|
||||
name={option.name}
|
||||
/>
|
||||
<div className="text-sm text-neutral-900">
|
||||
<div
|
||||
className={cx(
|
||||
'text-sm',
|
||||
'text-neutral-900',
|
||||
'transition-colors',
|
||||
'dark:text-neutral-400',
|
||||
'dark:group-hover:text-neutral-200',
|
||||
)}
|
||||
>
|
||||
{option.name}
|
||||
</div>
|
||||
</a>
|
||||
|
@ -252,11 +269,13 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
|
|||
'gap-x-2',
|
||||
'grid-cols-[24px,1fr]',
|
||||
'grid',
|
||||
'group',
|
||||
'items-center',
|
||||
'p-2',
|
||||
'transition-colors',
|
||||
'w-full',
|
||||
'hover:bg-neutral-200',
|
||||
'dark:hover:bg-neutral-700',
|
||||
)}
|
||||
onClick={() => {
|
||||
setText('');
|
||||
|
@ -268,7 +287,15 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
|
|||
iconUrl={option.iconUrl}
|
||||
name={option.name}
|
||||
/>
|
||||
<div className="text-sm text-neutral-900">
|
||||
<div
|
||||
className={cx(
|
||||
'text-sm',
|
||||
'text-neutral-900',
|
||||
'transition-colors',
|
||||
'dark:text-neutral-400',
|
||||
'dark:group-hover:text-neutral-200',
|
||||
)}
|
||||
>
|
||||
{option.name}
|
||||
</div>
|
||||
</a>
|
||||
|
|
|
@ -9,6 +9,7 @@ interface Props {
|
|||
onExpand?(): void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
export function ImageNode(props: Props) {
|
||||
return <div />;
|
||||
}
|
||||
|
|
|
@ -12,24 +12,24 @@ export function RealmsLogo(props: Props) {
|
|||
>
|
||||
<path
|
||||
d="M45.578 21.142C44.408 21.142 43.4 20.404 41.942 17.722L41.564 17.02C43.742 16.894 45.02 15.49 45.02 13.528C45.02 11.314 43.508 10 40.898 10H35.75V22.51H37.28V17.038H39.854L40.592 18.388C42.302 21.628 43.49 22.546 45.524 22.546C45.668 22.546 45.794 22.528 45.92 22.51V21.088C45.83 21.124 45.722 21.142 45.578 21.142ZM40.898 11.35C42.608 11.35 43.436 12.124 43.436 13.528C43.436 14.896 42.626 15.688 40.898 15.688H37.28V11.35H40.898Z"
|
||||
fill="#201F27"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M54.4684 19.522C53.8744 20.71 53.0644 21.43 51.6784 21.43C49.9684 21.43 48.9244 20.314 48.8344 18.532H55.5124C55.5304 18.424 55.5304 18.208 55.5304 18.046C55.5304 15.418 54.0184 13.654 51.5344 13.654C49.0864 13.654 47.2684 15.508 47.2684 18.19C47.2684 20.854 49.1404 22.726 51.6784 22.726C53.7304 22.726 54.9364 21.592 55.6204 20.242L54.4684 19.522ZM51.5164 14.932C53.0284 14.932 53.8564 15.778 53.9824 17.326H48.8704C49.0684 15.832 50.0764 14.932 51.5164 14.932Z"
|
||||
fill="#201F27"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M60.979 22.726C61.987 22.726 62.959 22.258 63.589 21.394V22.51H65.083V17.092C65.083 14.86 63.643 13.654 61.573 13.654C59.593 13.654 58.171 14.788 57.721 16.624L59.071 17.002C59.395 15.652 60.151 14.95 61.555 14.95C62.833 14.95 63.589 15.778 63.589 17.146V18.28C63.031 17.758 62.149 17.254 60.979 17.254C59.053 17.254 57.667 18.478 57.667 19.99C57.667 21.682 59.053 22.726 60.979 22.726ZM61.123 21.484C59.989 21.484 59.251 20.872 59.251 19.99C59.251 19.126 59.989 18.514 61.123 18.514C62.167 18.514 63.049 19.09 63.589 19.72V20.044C63.013 20.872 62.167 21.484 61.123 21.484Z"
|
||||
fill="#201F27"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M69.6409 22.51V10H68.1469V22.51H69.6409Z" fill="#201F27" />
|
||||
<path d="M69.6409 22.51V10H68.1469V22.51H69.6409Z" fill="currentColor" />
|
||||
<path
|
||||
d="M82.8761 13.654C81.5981 13.654 80.4821 14.068 79.7261 15.202C79.2401 14.14 78.2501 13.654 76.9721 13.654C75.8201 13.654 74.8481 14.122 74.2541 14.95V13.87H72.7601V22.51H74.2541V17.29C74.2541 15.778 75.2981 14.95 76.6301 14.95C77.9621 14.95 78.6641 15.778 78.6641 17.308V22.51H80.1581V17.308C80.1581 15.778 81.2021 14.95 82.5341 14.95C83.8661 14.95 84.5861 15.778 84.5861 17.308V22.51H86.0801V17.164C86.0801 15.004 84.9281 13.654 82.8761 13.654Z"
|
||||
fill="#201F27"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M92.4092 22.726C94.3892 22.726 95.8832 21.79 95.8832 20.08C95.8832 18.298 94.5152 17.83 93.0752 17.578L92.0492 17.398C90.8432 17.2 90.3932 16.894 90.3932 16.12C90.3932 15.346 91.0952 14.896 92.2832 14.896C93.6692 14.896 94.2992 15.688 94.4792 16.516L95.8292 16.156C95.5772 14.734 94.4612 13.654 92.3372 13.654C90.2312 13.654 88.9352 14.626 88.9352 16.138C88.9352 17.992 90.3392 18.46 91.8152 18.73L92.8232 18.892C93.9212 19.09 94.3352 19.36 94.3352 20.098C94.3352 21.034 93.5252 21.484 92.4452 21.484C91.0772 21.484 90.0332 20.962 89.8892 19.414L88.5032 19.666C88.5392 21.772 90.3392 22.726 92.4092 22.726Z"
|
||||
fill="#201F27"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M21.6658 28.1373C21.5204 28.1932 21.4034 28.0154 21.5064 27.8985C24.3031 24.7263 25.9999 20.5612 25.9999 15.9997C25.9999 11.4383 24.3031 7.27314 21.5064 4.10096C21.4034 3.98409 21.5203 3.80623 21.6658 3.86217C26.5405 5.7373 29.9999 10.4645 29.9999 15.9997C29.9999 21.5349 26.5405 26.2622 21.6658 28.1373Z"
|
||||
|
|
|
@ -30,11 +30,15 @@ export const Primary = forwardRef<HTMLButtonElement, Props>(function Primary(
|
|||
'text-white',
|
||||
'tracking-normal',
|
||||
'transition-colors',
|
||||
'dark:bg-sky-400',
|
||||
'dark:text-neutral-900',
|
||||
rest.className,
|
||||
!pending && 'active:bg-sky-500',
|
||||
!pending && 'dark:active:bg-sky-400',
|
||||
'disabled:bg-zinc-300',
|
||||
'disabled:cursor-not-allowed',
|
||||
!pending && 'hover:bg-sky-400',
|
||||
!pending && 'dark:hover:bg-sky-300',
|
||||
pending && 'cursor-not-allowed',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import { forwardRef } from 'react';
|
||||
|
||||
import { LoadingDots } from '@hub/components/LoadingDots';
|
||||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export const PrimaryAlt = forwardRef<HTMLButtonElement, Props>(
|
||||
function PrimaryAlt(props, ref) {
|
||||
const { pending, ...rest } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'bg-sky-500',
|
||||
'flex',
|
||||
'group',
|
||||
'h-10',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'p-3',
|
||||
'relative',
|
||||
'rounded',
|
||||
'text-white',
|
||||
'tracking-normal',
|
||||
'transition-colors',
|
||||
'dark:bg-neutral-50',
|
||||
'dark:text-black',
|
||||
rest.className,
|
||||
!pending && 'active:bg-sky-500',
|
||||
'disabled:bg-zinc-300',
|
||||
'disabled:cursor-not-allowed',
|
||||
!pending && 'hover:bg-sky-400',
|
||||
pending && 'cursor-not-allowed',
|
||||
!pending && 'dark:active:bg-neutral-300',
|
||||
!pending && 'dark:hover:bg-neutral-200',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!pending && !rest.disabled) {
|
||||
rest.onClick?.(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-current',
|
||||
'text-sm',
|
||||
'transition-all',
|
||||
'group-disabled:text-neutral-400',
|
||||
pending ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
{rest.children}
|
||||
</div>
|
||||
{pending && (
|
||||
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
|
@ -29,11 +29,15 @@ export const Secondary = forwardRef<HTMLButtonElement, Props>(
|
|||
'text-sky-500',
|
||||
'tracking-normal',
|
||||
'transition-colors',
|
||||
'dark:border-sky-400',
|
||||
'dark:text-sky-400',
|
||||
rest.className,
|
||||
'disabled:border-zinc-300',
|
||||
'disabled:cursor-not-allowed',
|
||||
!pending && 'hover:bg-sky-100',
|
||||
!pending && 'active:bg-sky-200',
|
||||
!pending && 'hover:bg-sky-400/10',
|
||||
!pending && 'active:bg-sky-400/20',
|
||||
pending && 'cursor-not-allowed',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { forwardRef } from 'react';
|
||||
|
||||
import { LoadingDots } from '@hub/components/LoadingDots';
|
||||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export const SecondaryAlt = forwardRef<HTMLButtonElement, Props>(
|
||||
function SecondaryAlt(props, ref) {
|
||||
const { pending, ...rest } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'border-sky-500',
|
||||
'border',
|
||||
'flex',
|
||||
'group',
|
||||
'h-10',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'p-3',
|
||||
'relative',
|
||||
'rounded',
|
||||
'text-sky-500',
|
||||
'tracking-normal',
|
||||
'transition-colors',
|
||||
'dark:border-neutral-50',
|
||||
'dark:text-neutral-50',
|
||||
rest.className,
|
||||
'disabled:border-zinc-300',
|
||||
'disabled:cursor-not-allowed',
|
||||
!pending && 'hover:bg-sky-100',
|
||||
!pending && 'active:bg-sky-200',
|
||||
pending && 'cursor-not-allowed',
|
||||
!pending && 'dark:hover:bg-neutral-50/20',
|
||||
!pending && 'disabled:dark:hover:bg-transparent',
|
||||
!pending && 'dark:active:border-neutral-300',
|
||||
!pending && 'dark:active:text-neutral-300',
|
||||
!pending && 'dark:hover:border-neutral-200',
|
||||
!pending && 'dark:hover:text-neutral-200',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!pending && !rest.disabled) {
|
||||
rest.onClick?.(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-current',
|
||||
'text-sm',
|
||||
'transition-all',
|
||||
'group-disabled:text-zinc-300',
|
||||
pending ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
{rest.children}
|
||||
</div>
|
||||
{pending && (
|
||||
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
|
@ -1,4 +1,6 @@
|
|||
export { Primary } from './Primary';
|
||||
export { PrimaryAlt } from './PrimaryAlt';
|
||||
export { Secondary } from './Secondary';
|
||||
export { SecondaryAlt } from './SecondaryAlt';
|
||||
export { SecondaryRed } from './SecondaryRed';
|
||||
export { Tertiary } from './Tertiary';
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import RadioButtonIcon from '@carbon/icons-react/lib/RadioButton';
|
||||
import RadioButtonCheckedIcon from '@carbon/icons-react/lib/RadioButtonChecked';
|
||||
|
||||
import * as Button from '@hub/components/controls/Button';
|
||||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value: boolean;
|
||||
valueFalseText: string;
|
||||
valueTrueText: string;
|
||||
disableValueTrue?: boolean;
|
||||
disableValueFalse?: boolean;
|
||||
onChange?(value: boolean): void;
|
||||
}
|
||||
|
||||
export function ButtonToggle(props: Props) {
|
||||
return (
|
||||
<div className={cx(props.className, 'grid', 'grid-cols-2', 'gap-x-2.5')}>
|
||||
{props.value === true ? (
|
||||
<Button.PrimaryAlt
|
||||
className="h-full"
|
||||
disabled={props.disableValueTrue}
|
||||
onClick={() => props.onChange?.(true)}
|
||||
>
|
||||
<RadioButtonCheckedIcon className="h-4 mr-1 w-4" />
|
||||
{props.valueTrueText}
|
||||
</Button.PrimaryAlt>
|
||||
) : (
|
||||
<Button.SecondaryAlt
|
||||
className="h-full opacity-60"
|
||||
disabled={props.disableValueTrue}
|
||||
onClick={() => props.onChange?.(true)}
|
||||
>
|
||||
<RadioButtonIcon className="h-4 mr-1 w-4" />
|
||||
{props.valueTrueText}
|
||||
</Button.SecondaryAlt>
|
||||
)}
|
||||
{props.value === false ? (
|
||||
<Button.PrimaryAlt
|
||||
className="h-full"
|
||||
disabled={props.disableValueFalse}
|
||||
onClick={() => props.onChange?.(false)}
|
||||
>
|
||||
<RadioButtonCheckedIcon className="h-4 mr-1 w-4" />
|
||||
{props.valueFalseText}
|
||||
</Button.PrimaryAlt>
|
||||
) : (
|
||||
<Button.SecondaryAlt
|
||||
className="h-full opacity-60"
|
||||
disabled={props.disableValueFalse}
|
||||
onClick={() => props.onChange?.(false)}
|
||||
>
|
||||
<RadioButtonIcon className="h-4 mr-1 w-4" />
|
||||
{props.valueFalseText}
|
||||
</Button.SecondaryAlt>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ButtonToggle.defaultProps = {
|
||||
valueFalseText: 'No',
|
||||
valueTrueText: 'Yes',
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
import type { PublicKey } from '@solana/web3.js';
|
||||
|
||||
import { useToast, ToastType } from '@hub/hooks/useToast';
|
||||
|
||||
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
address: string | PublicKey;
|
||||
}
|
||||
|
||||
export function CopyAddressButton(props: Props) {
|
||||
const { address, ...rest } = props;
|
||||
const { publish } = useToast();
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
onClick={async (e) => {
|
||||
try {
|
||||
const text =
|
||||
typeof address === 'string' ? address : address.toBase58();
|
||||
await navigator.clipboard.writeText(text);
|
||||
publish({
|
||||
type: ToastType.Success,
|
||||
title: 'Copied!',
|
||||
message: 'Address copied to clipboard',
|
||||
});
|
||||
} catch {
|
||||
publish({
|
||||
type: ToastType.Error,
|
||||
title: 'Could not copy address',
|
||||
message: 'There was an error when trying to copy this address',
|
||||
});
|
||||
}
|
||||
|
||||
rest.onClick?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -20,6 +20,12 @@ export function Input(props: Props) {
|
|||
'hover:border-zinc-400',
|
||||
'focus:border-sky-500',
|
||||
'placeholder:text-neutral-400',
|
||||
'dark:bg-neutral-800',
|
||||
'dark:border-neutral-700',
|
||||
'dark:placeholder:text-neutral-600',
|
||||
'dark:text-neutral-50',
|
||||
'disabled:opacity-50',
|
||||
'disabled:pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
import * as SliderRoot from '@radix-ui/react-slider';
|
||||
import { clamp } from 'ramda';
|
||||
|
||||
import cx from '@hub/lib/cx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
trackColor?: string;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
value: number;
|
||||
onRenderValue(value: number): React.ReactNode;
|
||||
onChange?(value: number): void;
|
||||
}
|
||||
|
||||
export function Slider(props: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
props.className,
|
||||
'gap-x-1.5',
|
||||
'grid-cols-[max-content,1fr,max-content]',
|
||||
'grid',
|
||||
'h-14',
|
||||
'items-center',
|
||||
'p-4',
|
||||
'rounded-md',
|
||||
'w-full',
|
||||
'dark:bg-neutral-800',
|
||||
)}
|
||||
>
|
||||
<div className="text-center min-w-[32px] dark:text-neutral-300">
|
||||
{props.onRenderValue(props.min)}
|
||||
</div>
|
||||
<SliderRoot.Root
|
||||
max={props.max}
|
||||
min={props.min}
|
||||
step={props.step}
|
||||
value={[clamp(props.min, props.max, props.value)]}
|
||||
onValueChange={(values) => props.onChange?.(values[0])}
|
||||
onValueCommit={(values) => props.onChange?.(values[0])}
|
||||
>
|
||||
<SliderRoot.Track className="block h-2 relative rounded-sm dark:bg-black">
|
||||
<SliderRoot.Range
|
||||
className={cx(
|
||||
'absolute',
|
||||
'block',
|
||||
'h-2',
|
||||
'rounded-sm',
|
||||
'bg-neutral-300',
|
||||
props.trackColor,
|
||||
)}
|
||||
/>
|
||||
<SliderRoot.Thumb
|
||||
className={cx(
|
||||
'-translate-y-1/2',
|
||||
'block',
|
||||
'cursor-pointer',
|
||||
'h-6',
|
||||
'mt-1',
|
||||
'rounded-full',
|
||||
'transition-colors',
|
||||
'w-6',
|
||||
'dark:bg-white',
|
||||
'dark:hover:bg-neutral-300',
|
||||
)}
|
||||
/>
|
||||
</SliderRoot.Track>
|
||||
</SliderRoot.Root>
|
||||
<div className="text-center min-w-[32px] dark:text-neutral-300">
|
||||
{props.onRenderValue(Math.max(props.max, props.value))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Slider.defaultProps = {
|
||||
step: 1,
|
||||
onRenderValue: (value) => String(value),
|
||||
} as Props;
|
|
@ -21,6 +21,10 @@ export function Textarea(props: Props) {
|
|||
'hover:border-zinc-400',
|
||||
'focus:border-sky-500',
|
||||
'placeholder:text-neutral-400',
|
||||
'dark:bg-neutral-800',
|
||||
'dark:border-neutral-700',
|
||||
'dark:placeholder:text-neutral-600',
|
||||
'dark:text-neutral-50',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
|
|
@ -15,6 +15,7 @@ interface Options {
|
|||
const cache = (() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
// eslint-disable-next-line
|
||||
get: <V>(key: string, options?: Options): V | null => null,
|
||||
set: <V>(key: string, value: V) => value,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { useContext } from 'react';
|
||||
|
||||
import { context } from '@hub/providers/Proposal';
|
||||
|
||||
export function useProposal() {
|
||||
return useContext(context);
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
export enum CreationProgressState {
|
||||
Complete = 'Complete',
|
||||
Error = 'Error',
|
||||
Processing = 'Processing',
|
||||
Ready = 'Ready',
|
||||
}
|
||||
|
||||
export interface CreationComplete {
|
||||
state: CreationProgressState.Complete;
|
||||
transactionsCompleted: number;
|
||||
}
|
||||
|
||||
export interface CreationError {
|
||||
state: CreationProgressState.Error;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export interface CreationProcessing {
|
||||
state: CreationProgressState.Processing;
|
||||
totalTransactions: number;
|
||||
transactionsCompleted: number;
|
||||
transactionsRemaining: number;
|
||||
}
|
||||
|
||||
export interface CreationReady {
|
||||
state: CreationProgressState.Ready;
|
||||
}
|
||||
|
||||
export type CreationProgress =
|
||||
| CreationComplete
|
||||
| CreationError
|
||||
| CreationProcessing
|
||||
| CreationReady;
|
||||
|
||||
export function useProposalCreationProgress() {
|
||||
const [progress, setProgress] = useState<CreationProgress>({
|
||||
state: CreationProgressState.Ready,
|
||||
});
|
||||
|
||||
const afterBatchSign = useCallback(
|
||||
(signedTransactionCount: number) => {
|
||||
setProgress({
|
||||
state: CreationProgressState.Processing,
|
||||
totalTransactions: signedTransactionCount,
|
||||
transactionsCompleted: 0,
|
||||
transactionsRemaining: signedTransactionCount,
|
||||
});
|
||||
},
|
||||
[setProgress],
|
||||
);
|
||||
|
||||
const afterAllTxConfirmed = useCallback(() => {
|
||||
setProgress((cur) => ({
|
||||
state: CreationProgressState.Complete,
|
||||
transactionsCompleted:
|
||||
cur.state === CreationProgressState.Processing
|
||||
? cur.totalTransactions
|
||||
: 0,
|
||||
}));
|
||||
}, [setProgress]);
|
||||
|
||||
const afterEveryTxConfirmation = useCallback(() => {
|
||||
setProgress((cur) => ({
|
||||
state: CreationProgressState.Processing,
|
||||
totalTransactions:
|
||||
cur.state === CreationProgressState.Processing
|
||||
? cur.totalTransactions
|
||||
: 0,
|
||||
transactionsCompleted:
|
||||
cur.state === CreationProgressState.Processing
|
||||
? cur.transactionsCompleted + 1
|
||||
: 0,
|
||||
transactionsRemaining:
|
||||
cur.state === CreationProgressState.Processing
|
||||
? cur.transactionsRemaining - 1
|
||||
: 0,
|
||||
}));
|
||||
}, [setProgress]);
|
||||
|
||||
const onError = useCallback(
|
||||
(error: any) => {
|
||||
setProgress({
|
||||
state: CreationProgressState.Error,
|
||||
error: error instanceof Error ? error : new Error(error),
|
||||
});
|
||||
},
|
||||
[setProgress],
|
||||
);
|
||||
|
||||
return {
|
||||
progress,
|
||||
callbacks: {
|
||||
afterBatchSign,
|
||||
afterAllTxConfirmed,
|
||||
afterEveryTxConfirmation,
|
||||
onError,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -3,8 +3,18 @@ import { useContext } from 'react';
|
|||
import { context } from '@hub/providers/Wallet';
|
||||
|
||||
export function useWallet() {
|
||||
const { connect, publicKey, signMessage, signTransation } = useContext(
|
||||
context,
|
||||
);
|
||||
return { connect, publicKey, signMessage, signTransation };
|
||||
const {
|
||||
connect,
|
||||
publicKey,
|
||||
signMessage,
|
||||
signTransaction,
|
||||
signAllTransactions,
|
||||
} = useContext(context);
|
||||
return {
|
||||
connect,
|
||||
publicKey,
|
||||
signMessage,
|
||||
signTransaction,
|
||||
signAllTransactions,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
|
||||
import { clusterApiUrl, Connection } from '@solana/web3.js';
|
||||
import { createContext, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { createContext, useEffect, useState } from 'react';
|
||||
|
||||
const DEVNET_RPC_ENDPOINT =
|
||||
process.env.DEVNET_RPC || 'https://api.dao.devnet.solana.com/';
|
||||
|
@ -68,7 +69,21 @@ interface Props {
|
|||
}
|
||||
|
||||
export function ClusterProvider(props: Props) {
|
||||
const [type, setType] = useState(ClusterType.Mainnet);
|
||||
const router = useRouter();
|
||||
const { cluster: urlCluster } = router.query;
|
||||
const [type, setType] = useState(
|
||||
typeof urlCluster === 'string' && urlCluster === 'devnet'
|
||||
? ClusterType.Devnet
|
||||
: ClusterType.Mainnet,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof urlCluster === 'string' && urlCluster === 'devnet') {
|
||||
setType(ClusterType.Devnet);
|
||||
} else {
|
||||
setType(ClusterType.Mainnet);
|
||||
}
|
||||
}, [urlCluster]);
|
||||
|
||||
const cluster =
|
||||
type === ClusterType.Devnet
|
||||
|
|
|
@ -1,36 +1,67 @@
|
|||
import { makeOperation } from '@urql/core';
|
||||
import { authExchange } from '@urql/exchange-auth';
|
||||
|
||||
export const auth = authExchange<{ token?: string }>({
|
||||
export const auth = authExchange<{ token?: string; cluster?: string }>({
|
||||
addAuthToOperation: ({ authState, operation }) => {
|
||||
const token = authState?.token || localStorage.getItem('user');
|
||||
|
||||
if (!token) {
|
||||
return operation;
|
||||
let enchancedOperation = operation;
|
||||
|
||||
if (authState?.cluster === 'devnet') {
|
||||
const fetchOptions =
|
||||
typeof enchancedOperation.context.fetchOptions === 'function'
|
||||
? enchancedOperation.context.fetchOptions()
|
||||
: enchancedOperation.context.fetchOptions || {};
|
||||
|
||||
enchancedOperation = makeOperation(
|
||||
enchancedOperation.kind,
|
||||
enchancedOperation,
|
||||
{
|
||||
...enchancedOperation.context,
|
||||
fetchOptions: {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...fetchOptions.headers,
|
||||
'x-environment': 'devnet',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const fetchOptions =
|
||||
typeof operation.context.fetchOptions === 'function'
|
||||
? operation.context.fetchOptions()
|
||||
: operation.context.fetchOptions || {};
|
||||
if (token) {
|
||||
const fetchOptions =
|
||||
typeof enchancedOperation.context.fetchOptions === 'function'
|
||||
? enchancedOperation.context.fetchOptions()
|
||||
: enchancedOperation.context.fetchOptions || {};
|
||||
|
||||
return makeOperation(operation.kind, operation, {
|
||||
...operation.context,
|
||||
fetchOptions: {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...fetchOptions.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
enchancedOperation = makeOperation(
|
||||
enchancedOperation.kind,
|
||||
enchancedOperation,
|
||||
{
|
||||
...enchancedOperation.context,
|
||||
fetchOptions: {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...fetchOptions.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
return enchancedOperation;
|
||||
},
|
||||
getAuth: async ({ authState }) => {
|
||||
if (!authState) {
|
||||
const token = localStorage.getItem('user');
|
||||
const token = localStorage.getItem('user') || undefined;
|
||||
const cluster = location.search.includes('cluster=devnet')
|
||||
? 'devnet'
|
||||
: undefined;
|
||||
|
||||
if (token) {
|
||||
return { token };
|
||||
if (token || cluster) {
|
||||
return { cluster, token };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -54,6 +54,7 @@ export const graphcache = async (
|
|||
DiscoverPage: (page) => String(page.version as number),
|
||||
DiscoverPageSpotlightItem: (item) => item.publicKey as string,
|
||||
DiscoverPageSpotlightItemStat: () => null,
|
||||
GovernanceRules: (rules) => rules.governanceAddress as string,
|
||||
Realm: (realm) => realm.publicKey as string,
|
||||
RealmAboutSection: () => null,
|
||||
RealmDocumentation: () => null,
|
||||
|
@ -83,6 +84,7 @@ export const graphcache = async (
|
|||
RealmProposalUserVote: () => null,
|
||||
RealmProposalVoteBreakdown: () => null,
|
||||
RealmTreasury: (treasury) => treasury.belongsTo as string,
|
||||
TokenBasedGovernanceRules: () => null,
|
||||
User: (user) => user.publicKey as string,
|
||||
},
|
||||
updates: {
|
||||
|
|
|
@ -0,0 +1,263 @@
|
|||
import { Wallet } from '@project-serum/anchor';
|
||||
import {
|
||||
serializeInstructionToBase64,
|
||||
getGovernance,
|
||||
getRealm,
|
||||
getTokenOwnerRecordForRealm,
|
||||
getRealmConfigAddress,
|
||||
GoverningTokenType,
|
||||
GoverningTokenConfig,
|
||||
RpcContext,
|
||||
GovernanceAccountParser,
|
||||
RealmConfigAccount,
|
||||
ProgramAccount,
|
||||
} from '@solana/spl-governance';
|
||||
import type {
|
||||
Connection,
|
||||
PublicKey,
|
||||
TransactionInstruction,
|
||||
Transaction,
|
||||
} from '@solana/web3.js';
|
||||
|
||||
import {
|
||||
InstructionDataWithHoldUpTime,
|
||||
createProposal as _createProposal,
|
||||
} from 'actions/createProposal';
|
||||
import { tryGetNftRegistrar } from 'VoteStakeRegistry/sdk/api';
|
||||
|
||||
import {
|
||||
vsrPluginsPks,
|
||||
nftPluginsPks,
|
||||
gatewayPluginsPks,
|
||||
switchboardPluginsPks,
|
||||
pythPluginsPks,
|
||||
} from '@hooks/useVotingPlugins';
|
||||
import { getRegistrarPDA as getPluginRegistrarPDA } from '@utils/plugin/accounts';
|
||||
import { getNfts } from '@utils/tokens';
|
||||
import { NFTWithMeta, VotingClient } from '@utils/uiTypes/VotePlugin';
|
||||
|
||||
import { fetchPlugins } from './fetchPlugins';
|
||||
|
||||
interface Args {
|
||||
callbacks?: Parameters<typeof _createProposal>[11];
|
||||
cluster?: string;
|
||||
connection: Connection;
|
||||
councilTokenMintPublicKey?: PublicKey;
|
||||
communityTokenMintPublicKey?: PublicKey;
|
||||
governancePublicKey: PublicKey;
|
||||
governingTokenMintPublicKey: PublicKey;
|
||||
instructions: TransactionInstruction[];
|
||||
isDraft: boolean;
|
||||
programPublicKey: PublicKey;
|
||||
proposalDescription: string;
|
||||
proposalTitle: string;
|
||||
realmPublicKey: PublicKey;
|
||||
requestingUserPublicKey: PublicKey;
|
||||
signTransaction(transaction: Transaction): Promise<Transaction>;
|
||||
signAllTransactions(transactions: Transaction[]): Promise<Transaction[]>;
|
||||
}
|
||||
|
||||
export async function createProposal(args: Args) {
|
||||
const [
|
||||
governance,
|
||||
realm,
|
||||
tokenOwnerRecord,
|
||||
realmConfigPublicKey,
|
||||
// eslint-disable-next-line
|
||||
councilTokenOwnerRecord,
|
||||
// eslint-disable-next-line
|
||||
communityTokenOwnerRecord,
|
||||
] = await Promise.all([
|
||||
getGovernance(args.connection, args.governancePublicKey),
|
||||
getRealm(args.connection, args.realmPublicKey),
|
||||
getTokenOwnerRecordForRealm(
|
||||
args.connection,
|
||||
args.programPublicKey,
|
||||
args.realmPublicKey,
|
||||
args.governingTokenMintPublicKey,
|
||||
args.requestingUserPublicKey,
|
||||
),
|
||||
getRealmConfigAddress(args.programPublicKey, args.realmPublicKey),
|
||||
args.councilTokenMintPublicKey
|
||||
? getTokenOwnerRecordForRealm(
|
||||
args.connection,
|
||||
args.programPublicKey,
|
||||
args.realmPublicKey,
|
||||
args.councilTokenMintPublicKey,
|
||||
args.requestingUserPublicKey,
|
||||
).catch(() => undefined)
|
||||
: undefined,
|
||||
args.communityTokenMintPublicKey
|
||||
? getTokenOwnerRecordForRealm(
|
||||
args.connection,
|
||||
args.programPublicKey,
|
||||
args.realmPublicKey,
|
||||
args.communityTokenMintPublicKey,
|
||||
args.requestingUserPublicKey,
|
||||
).catch(() => undefined)
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
const realmConfigAccountInfo = await args.connection.getAccountInfo(
|
||||
realmConfigPublicKey,
|
||||
);
|
||||
|
||||
const realmConfig: ProgramAccount<RealmConfigAccount> = realmConfigAccountInfo
|
||||
? GovernanceAccountParser(RealmConfigAccount)(
|
||||
realmConfigPublicKey,
|
||||
realmConfigAccountInfo,
|
||||
)
|
||||
: {
|
||||
pubkey: realmConfigPublicKey,
|
||||
owner: args.programPublicKey,
|
||||
account: new RealmConfigAccount({
|
||||
realm: args.realmPublicKey,
|
||||
communityTokenConfig: new GoverningTokenConfig({
|
||||
voterWeightAddin: undefined,
|
||||
maxVoterWeightAddin: undefined,
|
||||
tokenType: GoverningTokenType.Liquid,
|
||||
reserved: new Uint8Array(),
|
||||
}),
|
||||
councilTokenConfig: new GoverningTokenConfig({
|
||||
voterWeightAddin: undefined,
|
||||
maxVoterWeightAddin: undefined,
|
||||
tokenType: GoverningTokenType.Liquid,
|
||||
reserved: new Uint8Array(),
|
||||
}),
|
||||
reserved: new Uint8Array(),
|
||||
}),
|
||||
};
|
||||
|
||||
const serializedInstructions = args.instructions.map(
|
||||
serializeInstructionToBase64,
|
||||
);
|
||||
|
||||
const instructionData = serializedInstructions.map(
|
||||
(serializedInstruction) =>
|
||||
new InstructionDataWithHoldUpTime({
|
||||
governance,
|
||||
instruction: {
|
||||
governance,
|
||||
serializedInstruction,
|
||||
isValid: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const proposalIndex = governance.account.proposalCount;
|
||||
const votingPlugins = await fetchPlugins(
|
||||
args.connection,
|
||||
args.programPublicKey,
|
||||
{
|
||||
publicKey: args.requestingUserPublicKey,
|
||||
signTransaction: args.signTransaction,
|
||||
signAllTransactions: args.signAllTransactions,
|
||||
} as Wallet,
|
||||
args.cluster === 'devnet',
|
||||
);
|
||||
|
||||
const pluginPublicKey =
|
||||
realmConfig.account.communityTokenConfig.voterWeightAddin;
|
||||
let votingClient: VotingClient | undefined = undefined;
|
||||
let votingNfts: NFTWithMeta[] = [];
|
||||
|
||||
if (pluginPublicKey) {
|
||||
const pluginPublicKeyStr = pluginPublicKey.toBase58();
|
||||
let client: VotingClient['client'] = undefined;
|
||||
// Check for plugins in a particular order. I'm not sure why, but I
|
||||
// borrowed this from /hooks/useVotingPlugins.ts
|
||||
if (vsrPluginsPks.includes(pluginPublicKeyStr) && votingPlugins.vsrClient) {
|
||||
client = votingPlugins.vsrClient;
|
||||
}
|
||||
|
||||
if (nftPluginsPks.includes(pluginPublicKeyStr) && votingPlugins.nftClient) {
|
||||
client = votingPlugins.nftClient;
|
||||
|
||||
if (client && args.communityTokenMintPublicKey) {
|
||||
const programId = client.program.programId;
|
||||
const registrarPDA = (
|
||||
await getPluginRegistrarPDA(
|
||||
args.realmPublicKey,
|
||||
args.communityTokenMintPublicKey,
|
||||
programId,
|
||||
)
|
||||
).registrar;
|
||||
|
||||
const registrar: any = await tryGetNftRegistrar(registrarPDA, client);
|
||||
const collections: string[] =
|
||||
registrar?.collectionConfigs.map((x: any) =>
|
||||
x.collection.toBase58(),
|
||||
) || [];
|
||||
|
||||
const nfts = await getNfts(args.requestingUserPublicKey, {
|
||||
cluster: args.cluster,
|
||||
} as any);
|
||||
|
||||
votingNfts = nfts.filter(
|
||||
(nft) =>
|
||||
nft.collection &&
|
||||
nft.collection.mintAddress &&
|
||||
(nft.collection.verified ||
|
||||
typeof nft.collection.verified === 'undefined') &&
|
||||
collections.includes(nft.collection.mintAddress) &&
|
||||
nft.collection.creators?.filter((x) => x.verified).length > 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
switchboardPluginsPks.includes(pluginPublicKeyStr) &&
|
||||
votingPlugins.switchboardClient
|
||||
) {
|
||||
client = votingPlugins.switchboardClient;
|
||||
}
|
||||
|
||||
if (
|
||||
gatewayPluginsPks.includes(pluginPublicKeyStr) &&
|
||||
votingPlugins.gatewayClient
|
||||
) {
|
||||
client = votingPlugins.gatewayClient;
|
||||
}
|
||||
|
||||
if (
|
||||
pythPluginsPks.includes(pluginPublicKeyStr) &&
|
||||
votingPlugins.pythClient
|
||||
) {
|
||||
client = votingPlugins.pythClient;
|
||||
}
|
||||
|
||||
if (client) {
|
||||
votingClient = new VotingClient({
|
||||
realm,
|
||||
client,
|
||||
walletPk: args.requestingUserPublicKey,
|
||||
});
|
||||
|
||||
votingClient._setCurrentVoterNfts(votingNfts);
|
||||
}
|
||||
}
|
||||
|
||||
return _createProposal(
|
||||
{
|
||||
connection: args.connection,
|
||||
wallet: {
|
||||
publicKey: args.requestingUserPublicKey,
|
||||
signTransaction: args.signTransaction,
|
||||
signAllTransactions: args.signAllTransactions,
|
||||
},
|
||||
programId: args.programPublicKey,
|
||||
walletPubkey: args.requestingUserPublicKey,
|
||||
} as RpcContext,
|
||||
realm,
|
||||
args.governancePublicKey,
|
||||
tokenOwnerRecord,
|
||||
args.proposalTitle,
|
||||
args.proposalDescription,
|
||||
args.governingTokenMintPublicKey,
|
||||
proposalIndex,
|
||||
instructionData,
|
||||
args.isDraft,
|
||||
votingClient,
|
||||
args.callbacks,
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { AnchorProvider, Wallet } from '@project-serum/anchor';
|
||||
import {
|
||||
NftVoterClient,
|
||||
GatewayClient,
|
||||
} from '@solana/governance-program-library';
|
||||
import { Connection, PublicKey } from '@solana/web3.js';
|
||||
|
||||
import { PythClient } from 'pyth-staking-api';
|
||||
import { VsrClient } from 'VoteStakeRegistry/sdk/client';
|
||||
|
||||
import { SwitchboardQueueVoterClient } from '../../../SwitchboardVotePlugin/SwitchboardQueueVoterClient';
|
||||
|
||||
export async function fetchPlugins(
|
||||
connection: Connection,
|
||||
programPublicKey: PublicKey,
|
||||
wallet: Wallet,
|
||||
isDevnet?: boolean,
|
||||
) {
|
||||
const defaultOptions = AnchorProvider.defaultOptions();
|
||||
const anchorProvider = new AnchorProvider(connection, wallet, defaultOptions);
|
||||
|
||||
const [
|
||||
vsrClient,
|
||||
nftClient,
|
||||
gatewayClient,
|
||||
switchboardClient,
|
||||
pythClient,
|
||||
] = await Promise.all([
|
||||
VsrClient.connect(anchorProvider, programPublicKey, isDevnet),
|
||||
NftVoterClient.connect(anchorProvider, isDevnet),
|
||||
GatewayClient.connect(anchorProvider, isDevnet),
|
||||
SwitchboardQueueVoterClient.connect(anchorProvider, isDevnet),
|
||||
PythClient.connect(anchorProvider, connection.rpcEndpoint),
|
||||
]);
|
||||
|
||||
return {
|
||||
vsrClient,
|
||||
nftClient,
|
||||
gatewayClient,
|
||||
switchboardClient,
|
||||
pythClient,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
import { createContext } from 'react';
|
||||
|
||||
import { ClusterType, useCluster } from '@hub/hooks/useCluster';
|
||||
import {
|
||||
useProposalCreationProgress,
|
||||
CreationProgress,
|
||||
CreationProgressState,
|
||||
} from '@hub/hooks/useProposalCreationProgress';
|
||||
import { useToast, ToastType } from '@hub/hooks/useToast';
|
||||
import { useWallet } from '@hub/hooks/useWallet';
|
||||
|
||||
import { createProposal } from './createProposal';
|
||||
|
||||
type CreateProposalsArgs = Omit<
|
||||
Parameters<typeof createProposal>[0],
|
||||
| 'callbacks'
|
||||
| 'connection'
|
||||
| 'cluster'
|
||||
| 'signTransaction'
|
||||
| 'signAllTransactions'
|
||||
| 'requestingUserPublicKey'
|
||||
>;
|
||||
|
||||
interface Value {
|
||||
createProposal(
|
||||
args: CreateProposalsArgs,
|
||||
): Promise<Awaited<ReturnType<typeof createProposal>> | null>;
|
||||
progress: CreationProgress;
|
||||
}
|
||||
|
||||
export const DEFAULT: Value = {
|
||||
createProposal: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
progress: { state: CreationProgressState.Ready },
|
||||
};
|
||||
|
||||
export const context = createContext(DEFAULT);
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProposalProvider(props: Props) {
|
||||
const [cluster] = useCluster();
|
||||
const { connect, signTransaction, signAllTransactions } = useWallet();
|
||||
const { publish } = useToast();
|
||||
const { callbacks, progress } = useProposalCreationProgress();
|
||||
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
progress,
|
||||
createProposal: async (args) => {
|
||||
try {
|
||||
const publicKey = await connect();
|
||||
|
||||
if (!publicKey) {
|
||||
throw new Error('User must be signed in');
|
||||
}
|
||||
|
||||
return createProposal({
|
||||
...args,
|
||||
callbacks,
|
||||
cluster:
|
||||
cluster.type === ClusterType.Devnet
|
||||
? 'devnet'
|
||||
: cluster.type === ClusterType.Mainnet
|
||||
? 'mainnet'
|
||||
: 'localnet',
|
||||
connection: cluster.connection,
|
||||
requestingUserPublicKey: publicKey,
|
||||
signAllTransactions,
|
||||
signTransaction,
|
||||
});
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : 'Something went wrong';
|
||||
|
||||
publish({
|
||||
message,
|
||||
type: ToastType.Error,
|
||||
title: 'Couuld not create a proposal',
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
);
|
||||
}
|
|
@ -6,6 +6,7 @@ import cx from '@hub/lib/cx';
|
|||
import { ClusterProvider } from './Cluster';
|
||||
import { GraphQLProvider } from './GraphQL';
|
||||
import { JWTProvider } from './JWT';
|
||||
import { ProposalProvider } from './Proposal';
|
||||
import { ToastProvider } from './Toast';
|
||||
import { UserPrefsProvider } from './UserPrefs';
|
||||
import { WalletProvider } from './Wallet';
|
||||
|
@ -33,7 +34,9 @@ export function RootProvider(props: Props) {
|
|||
<WalletProvider>
|
||||
<GraphQLProvider>
|
||||
<UserPrefsProvider>
|
||||
<Tooltip.Provider>{props.children}</Tooltip.Provider>
|
||||
<ProposalProvider>
|
||||
<Tooltip.Provider>{props.children}</Tooltip.Provider>
|
||||
</ProposalProvider>
|
||||
</UserPrefsProvider>
|
||||
</GraphQLProvider>
|
||||
</WalletProvider>
|
||||
|
|
|
@ -82,6 +82,9 @@ function ToastItem(props: ToastModel & { onOpenChange(open: boolean): void }) {
|
|||
'text-left',
|
||||
'translate-x-full',
|
||||
'transition-all',
|
||||
'dark:bg-neutral-900',
|
||||
'dark:border',
|
||||
'dark:border-neutral-600',
|
||||
'hover:scale-105',
|
||||
show ? 'opacity-100' : 'opacity-0',
|
||||
show ? 'h-auto' : 'h-0',
|
||||
|
@ -95,13 +98,18 @@ function ToastItem(props: ToastModel & { onOpenChange(open: boolean): void }) {
|
|||
})}
|
||||
</div>
|
||||
<Toast.Title
|
||||
className={cx('text-neutral-900', 'text-sm', 'font-bold')}
|
||||
className={cx(
|
||||
'text-neutral-900',
|
||||
'text-sm',
|
||||
'font-bold',
|
||||
'dark:text-white',
|
||||
)}
|
||||
>
|
||||
{props.title || defaultTitle(props)}
|
||||
</Toast.Title>
|
||||
</div>
|
||||
<div className="pl-6">
|
||||
<Toast.Description className="text-sm text-zinc-500">
|
||||
<Toast.Description className="text-sm text-neutral-500">
|
||||
{props.message}
|
||||
</Toast.Description>
|
||||
</div>
|
||||
|
|
|
@ -9,7 +9,8 @@ interface Value {
|
|||
connect(): Promise<PublicKey>;
|
||||
publicKey?: PublicKey;
|
||||
signMessage: NonNullable<WalletContextState['signMessage']>;
|
||||
signTransation: NonNullable<WalletContextState['signTransaction']>;
|
||||
signTransaction: NonNullable<WalletContextState['signTransaction']>;
|
||||
signAllTransactions: NonNullable<WalletContextState['signAllTransactions']>;
|
||||
}
|
||||
|
||||
export const DEFAULT: Value = {
|
||||
|
@ -20,7 +21,10 @@ export const DEFAULT: Value = {
|
|||
signMessage: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
signTransation: async () => {
|
||||
signTransaction: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
signAllTransactions: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
};
|
||||
|
@ -44,10 +48,14 @@ function WalletProviderInner(props: Props) {
|
|||
const { signMessage } = await getAdapter();
|
||||
return signMessage(message);
|
||||
},
|
||||
signTransation: async (transaction) => {
|
||||
signTransaction: async (transaction) => {
|
||||
const { signTransaction } = await getAdapter();
|
||||
return signTransaction(transaction);
|
||||
},
|
||||
signAllTransactions: async (transactions) => {
|
||||
const { signAllTransactions } = await getAdapter();
|
||||
return signAllTransactions(transactions);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
|
|
@ -22,6 +22,7 @@ interface Wallet {
|
|||
publicKey: PublicKey;
|
||||
signMessage: NonNullable<WalletContextState['signMessage']>;
|
||||
signTransaction: NonNullable<WalletContextState['signTransaction']>;
|
||||
signAllTransactions: NonNullable<WalletContextState['signAllTransactions']>;
|
||||
wallet: BaseWallet;
|
||||
}
|
||||
|
||||
|
@ -47,7 +48,14 @@ interface Props {
|
|||
}
|
||||
|
||||
function WalletSelectorInner(props: Props) {
|
||||
const { wallets, signMessage, signTransaction, select, wallet } = useWallet();
|
||||
const {
|
||||
wallets,
|
||||
signMessage,
|
||||
signTransaction,
|
||||
signAllTransactions,
|
||||
select,
|
||||
wallet,
|
||||
} = useWallet();
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
const [publicKey, setPublicKey] = useState<PublicKey | null>(null);
|
||||
const [shouldConnect, setShouldConnect] = useState(false);
|
||||
|
@ -100,6 +108,11 @@ function WalletSelectorInner(props: Props) {
|
|||
signMessage,
|
||||
signTransaction,
|
||||
wallet,
|
||||
signAllTransactions:
|
||||
signAllTransactions ||
|
||||
(() => {
|
||||
throw new Error('signAllTransactions not available');
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, [signMessage, signTransaction, publicKey, wallet]);
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export type FormCallbacks<V extends object> = {
|
||||
[K in keyof V as `on${Capitalize<K extends string ? K : never>}Change`]+?: (
|
||||
value: V[K],
|
||||
) => void;
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
import { FormCallbacks } from './FormCallbacks';
|
||||
|
||||
export type FormProps<P extends object> = P & FormCallbacks<P>;
|
|
@ -0,0 +1,4 @@
|
|||
export enum GovernanceTokenType {
|
||||
Council = 'Council',
|
||||
Community = 'Community',
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export enum GovernanceVoteTipping {
|
||||
Disabled = 'Disabled',
|
||||
Early = 'Early',
|
||||
Strict = 'Strict',
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import * as IT from 'io-ts';
|
||||
|
||||
import { GovernanceTokenType as _GovernanceTokenType } from '../GovernanceTokenType';
|
||||
|
||||
export const GovernanceTokenTypeCouncil = IT.literal(
|
||||
_GovernanceTokenType.Council,
|
||||
);
|
||||
export const GovernanceTokenTypeCommunity = IT.literal(
|
||||
_GovernanceTokenType.Community,
|
||||
);
|
||||
|
||||
export const GovernanceTokenType = IT.union([
|
||||
GovernanceTokenTypeCouncil,
|
||||
GovernanceTokenTypeCommunity,
|
||||
]);
|
|
@ -0,0 +1,19 @@
|
|||
import * as IT from 'io-ts';
|
||||
|
||||
import { GovernanceVoteTipping as _GovernanceVoteTipping } from '../GovernanceVoteTipping';
|
||||
|
||||
export const GovernanceVoteTippingDisabled = IT.literal(
|
||||
_GovernanceVoteTipping.Disabled,
|
||||
);
|
||||
export const GovernanceVoteTippingEarly = IT.literal(
|
||||
_GovernanceVoteTipping.Early,
|
||||
);
|
||||
export const GovernanceVoteTippingStrict = IT.literal(
|
||||
_GovernanceVoteTipping.Strict,
|
||||
);
|
||||
|
||||
export const GovernanceVoteTipping = IT.union([
|
||||
GovernanceVoteTippingDisabled,
|
||||
GovernanceVoteTippingEarly,
|
||||
GovernanceVoteTippingStrict,
|
||||
]);
|
|
@ -10,6 +10,7 @@ import { Asset, Token } from './Asset'
|
|||
export interface CommonRules {
|
||||
maxVotingTime: number
|
||||
minInstructionHoldupTime: number
|
||||
votingCoolOffSeconds: number
|
||||
}
|
||||
|
||||
export interface Rules {
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
"@radix-ui/react-radio-group": "1.1.0",
|
||||
"@radix-ui/react-select": "1.0.0",
|
||||
"@radix-ui/react-separator": "1.0.0",
|
||||
"@radix-ui/react-slider": "1.1.0",
|
||||
"@radix-ui/react-tabs": "1.0.0",
|
||||
"@radix-ui/react-toast": "1.0.0",
|
||||
"@radix-ui/react-toolbar": "1.0.0",
|
||||
|
|
|
@ -22,7 +22,8 @@ export default function App({ Component, pageProps, router }: AppProps) {
|
|||
|
||||
if (
|
||||
router.pathname.startsWith('/verify-wallet') ||
|
||||
router.pathname.startsWith('/matchday/verify-wallet')
|
||||
router.pathname.startsWith('/matchday/verify-wallet') ||
|
||||
router.pathname.startsWith('/realm/[id]/governance')
|
||||
) {
|
||||
return (
|
||||
<HubApp minimal>
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
import { PublicKey } from '@solana/web3.js'
|
||||
|
||||
import { EditWalletRules } from '@hub/components/EditWalletRules'
|
||||
import { ECOSYSTEM_PAGE } from '@hub/lib/constants'
|
||||
|
||||
export default function EditWallet() {
|
||||
const router = useRouter()
|
||||
const { id, governanceId } = router.query
|
||||
|
||||
if (typeof governanceId !== 'string') {
|
||||
throw new Error('Not a valid wallet address')
|
||||
}
|
||||
|
||||
const governanceAddress = new PublicKey(governanceId)
|
||||
|
||||
useEffect(() => {
|
||||
if (id === ECOSYSTEM_PAGE.toBase58()) {
|
||||
router.replace('/ecosystem')
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree
|
||||
}, [id])
|
||||
|
||||
if (id === ECOSYSTEM_PAGE.toBase58()) {
|
||||
return <div />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>Edit Wallet Rules</title>
|
||||
<meta property="og:title" content="Edit Wallet" key="title" />
|
||||
</Head>
|
||||
<EditWalletRules
|
||||
className="min-h-screen"
|
||||
realmUrlId={id as string}
|
||||
governanceAddress={governanceAddress}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
const lineClamp = require('@tailwindcss/line-clamp')
|
||||
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
|
|
18
yarn.lock
18
yarn.lock
|
@ -3593,6 +3593,24 @@
|
|||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.0"
|
||||
|
||||
"@radix-ui/react-slider@1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slider/-/react-slider-1.1.0.tgz#b3fdaca27619150e9e6067ad9f979a4535f68d5e"
|
||||
integrity sha512-5H/QB4xD3GF9UfoSCVLBx2JjlXamMcmTyL6gr4kkd/MiAGaYB0W7Exi4MQa0tJApBFJe+KmS5InKCI56p2kmjA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/number" "1.0.0"
|
||||
"@radix-ui/primitive" "1.0.0"
|
||||
"@radix-ui/react-collection" "1.0.1"
|
||||
"@radix-ui/react-compose-refs" "1.0.0"
|
||||
"@radix-ui/react-context" "1.0.0"
|
||||
"@radix-ui/react-direction" "1.0.0"
|
||||
"@radix-ui/react-primitive" "1.0.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.0"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.0"
|
||||
"@radix-ui/react-use-previous" "1.0.0"
|
||||
"@radix-ui/react-use-size" "1.0.0"
|
||||
|
||||
"@radix-ui/react-slot@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.0.tgz#7fa805b99891dea1e862d8f8fbe07f4d6d0fd698"
|
||||
|
|
Loading…
Reference in New Issue