Skip to content

Commit

Permalink
Create delegate page
Browse files Browse the repository at this point in the history
Define delegate metadata schema
Update nav icons
  • Loading branch information
jmrossy committed Mar 8, 2024
1 parent 39529c3 commit 7321b25
Show file tree
Hide file tree
Showing 16 changed files with 540 additions and 15 deletions.
3 changes: 3 additions & 0 deletions public/logos/delegatees/default.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
158 changes: 158 additions & 0 deletions src/app/delegate/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use client';

import clsx from 'clsx';
import { useMemo, useState } from 'react';
import { FullWidthSpinner } from 'src/components/animation/Spinner';
import { BackLink } from 'src/components/buttons/BackLink';
import { ExternalLink } from 'src/components/buttons/ExternalLink';
import { ChevronIcon } from 'src/components/icons/Chevron';
import { Section } from 'src/components/layout/Section';
import { links } from 'src/config/links';
import { ProposalBadgeRow } from 'src/features/governance/components/ProposalCard';
import { ProposalUpvotersTable } from 'src/features/governance/components/ProposalUpvotersTable';
import {
ProposalUpvoteButton,
ProposalVoteButtons,
} from 'src/features/governance/components/ProposalVoteButtons';
import {
ProposalQuorumChart,
ProposalVoteChart,
} from 'src/features/governance/components/ProposalVoteChart';
import { ProposalVotersTable } from 'src/features/governance/components/ProposalVotersTable';
import {
MergedProposalData,
useGovernanceProposals,
} from 'src/features/governance/hooks/useGovernanceProposals';
import { useProposalContent } from 'src/features/governance/hooks/useProposalContent';
import { ProposalStage } from 'src/features/governance/types';
import { useWindowSize } from 'src/styles/mediaQueries';
import { usePageInvariant } from 'src/utils/navigation';
import { trimToLength } from 'src/utils/strings';
import { getHumanReadableDuration } from 'src/utils/time';
import styles from './styles.module.css';

const ID_PARAM_REGEX = /^(cgp-)?(\d+)$/;

export const dynamicParams = true;

export default function Page({ params: { id } }: { params: { id: string } }) {
const { proposals } = useGovernanceProposals();

const propData = useMemo(() => {
if (!proposals || !id) return undefined;
const matches = ID_PARAM_REGEX.exec(id);
if (matches?.[1] === 'cgp-') {
const cgpId = parseInt(matches[2]);
return proposals.find((p) => p.metadata?.cgp === cgpId);
} else if (matches?.[2]) {
const propId = parseInt(matches[2]);
return proposals.find((p) => p.proposal?.id === propId);
} else {
return undefined;
}
}, [proposals, id]);

usePageInvariant(!proposals || propData, '/governance', 'Proposal not found');

const [isMenuCollapsed, setIsMenuCollapsed] = useState(false);

if (!propData) {
return <FullWidthSpinner>Loading proposals</FullWidthSpinner>;
}

return (
<Section containerClassName="mt-4 lg:flex lg:flex-row lg:gap-6">
<ProposalContent propData={propData} />
{propData.stage !== ProposalStage.None && (
<div className="fixed bottom-0 left-0 right-0 z-10 bg-white lg:static lg:bg-transparent">
<button
onClick={() => setIsMenuCollapsed(!isMenuCollapsed)}
className="flex-center w-full space-x-4 border-y border-taupe-300 py-2 lg:hidden"
>
<span>{isMenuCollapsed ? 'More' : 'Less'}</span>
<ChevronIcon direction={isMenuCollapsed ? 'n' : 's'} width={15} height={10} />
</button>
<div
className={clsx(
'transition-all duration-300',
isMenuCollapsed ? 'max-h-0 lg:max-h-none' : 'max-h-screen lg:max-h-none',
)}
>
<ProposalChainData propData={propData} />
</div>
</div>
)}
</Section>
);
}

function ProposalContent({ propData }: { propData: MergedProposalData }) {
const { proposal, metadata } = propData;
const title = trimToLength(metadata?.title || `Proposal #${proposal?.id}`, 80);

const { content, isLoading } = useProposalContent(metadata);

return (
<div className="space-y-3">
<BackLink href="/governance">Browse proposals</BackLink>
<h1 className="font-serif text-2xl md:text-2xl">{title}</h1>
<ProposalBadgeRow data={propData} showProposer />
{isLoading && !content && <FullWidthSpinner>Loading proposal content</FullWidthSpinner>}
{!isLoading && !content && (
<div className="flex flex-col items-start justify-center space-y-3 py-8">
<p className="text-taupe-600">
No valid CGP data found for this proposal. It may be missing or malformed.
</p>
<p className="text-taupe-600">You can still upvote and/or vote for this proposal.</p>
<p className="text-taupe-600">
See the{' '}
<ExternalLink href={links.governance} className="underline">
Celo Governance repository
</ExternalLink>{' '}
for more information.
</p>
</div>
)}
{content && (
<div
dangerouslySetInnerHTML={{ __html: content }}
className={`space-y-4 pb-4 ${styles.proposal}`}
></div>
)}
</div>
);
}

function ProposalChainData({ propData }: { propData: MergedProposalData }) {
const { id, stage, proposal } = propData;
const expiryTimestamp = proposal?.expiryTimestamp;

const windowSize = useWindowSize();
const showTables = windowSize?.width && windowSize.width > 1024;

if (stage === ProposalStage.None) return null;

return (
<div className="space-y-4 lg:min-w-[20rem]">
<div className="space-y-4 border-taupe-300 p-3 lg:border">
{stage === ProposalStage.Queued && <ProposalUpvoteButton proposalId={id} />}
{stage === ProposalStage.Referendum && <ProposalVoteButtons proposalId={id} />}
{expiryTimestamp && (
<div>{`Voting ends in ${getHumanReadableDuration(expiryTimestamp)}`}</div>
)}
{stage >= ProposalStage.Referendum && <ProposalVoteChart propData={propData} />}
{stage === ProposalStage.Referendum && <ProposalQuorumChart propData={propData} />}
</div>
{showTables && stage >= ProposalStage.Queued && stage < ProposalStage.Referendum && (
<div className="border-taupe-300 p-3 lg:border">
<ProposalUpvotersTable propData={propData} />
</div>
)}
{showTables && stage >= ProposalStage.Referendum && (
<div className="overflow-auto border-taupe-300 p-3 lg:border">
<ProposalVotersTable propData={propData} />
</div>
)}
</div>
);
}
80 changes: 80 additions & 0 deletions src/app/delegate/[id]/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
.proposal {
max-width: min(96vw, 768px);
overflow: auto;
}

.proposal h1,
.proposal h2,
.proposal h3,
.proposal h4,
.proposal a,
.proposal p {
overflow-wrap: break-word;
word-wrap: break-word;
}

.proposal h1,
.proposal h2,
.proposal h3,
.proposal h4 {
font-family: 'Garamond', serif;
}

.proposal h1,
.proposal h2 {
font-size: 24px;
}

.proposal h3 {
font-size: 20px;
}

.proposal h4 {
font-size: 18px;
}

.proposal a {
text-decoration: underline;
cursor: pointer;
}

.proposal a:hover {
opacity: 0.75;
}

.proposal a:active {
opacity: 0.65;
}

.proposal ol,
.proposal ul {
padding-left: 1rem;
}

.proposal ul {
list-style: disc;
}

.proposal ol {
list-style: decimal;
}

.proposal li {
padding-left: 1rem;
margin-top: 0.6rem;
}

.proposal pre {
padding: 0.5rem;
background-color: rgba(0, 0, 0, 0.04);
overflow-x: hidden;
}

.proposal img {
display: none;
}

.proposal table th,
.proposal table td {
padding: 0.2rem 0.5rem;
}
103 changes: 103 additions & 0 deletions src/app/delegate/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use client';

import { useMemo, useState } from 'react';
import { Fade } from 'src/components/animation/Fade';
import { FullWidthSpinner } from 'src/components/animation/Spinner';
import { ExternalLink } from 'src/components/buttons/ExternalLink';
import { TabHeaderButton } from 'src/components/buttons/TabHeaderButton';
import { SearchField } from 'src/components/input/SearchField';
import { CtaCard } from 'src/components/layout/CtaCard';
import { Section } from 'src/components/layout/Section';
import { H1 } from 'src/components/text/headers';
import { links } from 'src/config/links';
import { Delegatee } from 'src/features/delegation/types';
import { useDelegatees } from 'src/features/delegation/useDelegatees';

export default function Page() {
return (
<>
<Section className="mt-4" containerClassName="space-y-5">
<H1>Delegate voting power</H1>
<RegisterCtaCard />
<DelegateeList />
</Section>
</>
);
}

function DelegateeList() {
const [searchQuery, setSearchQuery] = useState<string>('');

const { delegatees } = useDelegatees();
const filteredDelegatees = useFilteredDelegatees({ delegatees, searchQuery });

return (
<div className="">
<div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center md:justify-between"></div>
{filteredDelegatees ? (
<Fade show>
<div className="flex justify-between">
<TabHeaderButton isActive={true} count={filteredDelegatees.length}>
Delegates
</TabHeaderButton>
<SearchField
value={searchQuery}
setValue={setSearchQuery}
placeholder="Search delegates"
className="w-full text-sm md:w-64"
/>
</div>
<div className="mt-6 divide-y divide-taupe-300">
{filteredDelegatees.length ? (
filteredDelegatees.map((data, i) => (
<div key={i} className="py-6 first:pt-0">
{JSON.stringify(data)}
</div>
))
) : (
<div className="flex justify-center py-10">
<p className="text-center text-taupe-600">No delegates found</p>
</div>
)}
</div>
</Fade>
) : (
<FullWidthSpinner>Loading delegate data</FullWidthSpinner>
)}
</div>
);
}

function useFilteredDelegatees({
delegatees,
searchQuery,
}: {
delegatees?: Delegatee[];
searchQuery: string;
}) {
return useMemo<Delegatee[] | undefined>(() => {
if (!delegatees) return undefined;
const query = searchQuery.trim().toLowerCase();
return delegatees.filter(
(d) =>
!query ||
d.name.toLowerCase().includes(query) ||
d.address.toLowerCase().includes(query) ||
d.description.toLowerCase().includes(query),
);
}, [delegatees, searchQuery]);
}

export function RegisterCtaCard() {
return (
<CtaCard>
<div className="space-y-2">
<h3 className="font-serif text-xl sm:text-2xl">Passionate about Celo governance?</h3>
<p className="text-sm sm:text-base">Add your information on Github to be listed here.</p>
</div>
<ExternalLink href={links.delegate} className="btn btn-primary rounded-full border-taupe-300">
Register as a delegate
</ExternalLink>
</CtaCard>
);
}
2 changes: 1 addition & 1 deletion src/components/buttons/TabHeaderButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function TabHeaderButton({
count,
onClick,
children,
}: PropsWithChildren<{ isActive: boolean; onClick: () => void; count?: number | string }>) {
}: PropsWithChildren<{ isActive: boolean; onClick?: () => void; count?: number | string }>) {
const [hover, setHover] = useState(false);
return (
<button
Expand Down
16 changes: 16 additions & 0 deletions src/components/layout/CtaCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';

// Call to action card
export function CtaCard({ children, className }: PropsWithChildren<{ className?: string }>) {
return (
<div
className={clsx(
'flex items-center justify-between space-x-6 border border-taupe-300 bg-white bg-diamond-texture bg-right-bottom bg-no-repeat px-3 py-4 md:px-5 md:py-6',
className,
)}
>
{children}
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/nav/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import { CeloGlyph } from 'src/components/logos/Celo';
import { DropdownMenu } from 'src/components/menus/Dropdown';
import Bridge from 'src/images/icons/bridge.svg';
import Dashboard from 'src/images/icons/dashboard.svg';
import Delegate from 'src/images/icons/delegate.svg';
import Governance from 'src/images/icons/governance.svg';
import Staking from 'src/images/icons/staking.svg';
import { useAccount } from 'wagmi';

const LINKS = (isWalletConnected?: boolean) => [
{ label: 'Staking', to: '/', icon: Staking },
{ label: 'Governance', to: '/governance', icon: Governance },
{ label: 'Delegate', to: '/delegate', icon: Delegate },
{ label: 'Bridge', to: '/bridge', icon: Bridge },
...(isWalletConnected ? [{ label: 'Dashboard', to: '/account', icon: Dashboard }] : []),
];
Expand Down
Loading

0 comments on commit 7321b25

Please sign in to comment.