Adopt shared WebUI structural primitives
This commit is contained in:
@@ -10,8 +10,15 @@ import { ActionToolbar,
|
|||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
|
MetricCard,
|
||||||
|
MetricGrid,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
WorkspaceLayout,
|
||||||
hasScope,
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
@@ -144,28 +151,40 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="voting-page">
|
<main className="voting-page">
|
||||||
<div className="voting-shell">
|
<WorkspaceLayout
|
||||||
<aside className="voting-catalogue">
|
variant="split"
|
||||||
<ActionToolbar className="voting-toolbar">
|
primarySize="default"
|
||||||
|
primaryScrollable={false}
|
||||||
|
contentScrollable={false}
|
||||||
|
surface="contained"
|
||||||
|
primaryClassName="voting-catalogue"
|
||||||
|
contentClassName="voting-workspace"
|
||||||
|
primaryLabel="Ballots"
|
||||||
|
contentLabel="Ballot details"
|
||||||
|
interfaceId="voting.workspace"
|
||||||
|
helpContextId="voting.workspace"
|
||||||
|
helpModuleId="voting"
|
||||||
|
primary={<>
|
||||||
|
<ActionToolbar surface="panel-header" className="voting-toolbar">
|
||||||
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
|
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
|
||||||
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
|
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
|
||||||
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
|
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
|
||||||
</ActionToolbar>
|
</ActionToolbar>
|
||||||
<PageScrollViewport className="voting-list-viewport">
|
<PageScrollViewport className="voting-list-viewport">
|
||||||
{loading && <LoadingIndicator label="Loading ballots" />}
|
{loading && <LoadingIndicator label="Loading ballots" />}
|
||||||
{!loading && items.length === 0 && <div className="voting-empty">No ballots.</div>}
|
{!loading && items.length === 0 && <StatePanel size="compact" description="No ballots." />}
|
||||||
<div className="voting-list" role="list">
|
<SelectionList variant="navigation" label="Ballots">
|
||||||
{items.map((item) => <button type="button" role="listitem" className={`voting-list-row${item.id === selectedId ? " is-selected" : ""}`} key={item.id} onClick={() => setSelectedId(item.id)}>
|
{items.map((item) => <SelectionListItem selected={item.id === selectedId} key={item.id} onClick={() => setSelectedId(item.id)}>
|
||||||
<span><strong>{item.title}</strong><small>{humanize(item.assurance_profile)}</small></span>
|
<SelectionListItemContent title={item.title} description={humanize(item.assurance_profile)} />
|
||||||
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
||||||
</button>)}
|
</SelectionListItem>)}
|
||||||
</div>
|
</SelectionList>
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</aside>
|
</>}
|
||||||
<section className="voting-workspace">
|
>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||||
{!selected && !loading && <div className="voting-empty">Select a ballot.</div>}
|
{!selected && !loading && <StatePanel size="fill" title="Ballots" description="Select a ballot." />}
|
||||||
{selected && <PageScrollViewport className="voting-detail-viewport">
|
{selected && <PageScrollViewport className="voting-detail-viewport">
|
||||||
<div className="voting-detail-heading">
|
<div className="voting-detail-heading">
|
||||||
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
|
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
|
||||||
@@ -179,12 +198,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
{selected.state !== "annulled" && <Button variant="danger" disabled={busy || !canAdmin} disabledReason={busy ? VOTING_I18N.busy : !canAdmin ? VOTING_I18N.adminReason : undefined} onClick={() => setReasonAction("annul")}>Annul</Button>}
|
{selected.state !== "annulled" && <Button variant="danger" disabled={busy || !canAdmin} disabledReason={busy ? VOTING_I18N.busy : !canAdmin ? VOTING_I18N.adminReason : undefined} onClick={() => setReasonAction("annul")}>Annul</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="voting-metrics">
|
<MetricGrid columns={4} spacing="block">
|
||||||
<Metric label="Electors" value={selected.electorate.length} />
|
<MetricCard density="compact" label="Electors" value={selected.electorate.length} />
|
||||||
<Metric label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
<MetricCard density="compact" label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
||||||
<Metric label="Quorum" value={selected.quorum_weight} />
|
<MetricCard density="compact" label="Quorum" value={selected.quorum_weight} />
|
||||||
<Metric label="Assurance" value={humanize(selected.assurance_profile)} />
|
<MetricCard density="compact" label="Assurance" value={humanize(selected.assurance_profile)} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
{selected.description && <p className="voting-description">{selected.description}</p>}
|
{selected.description && <p className="voting-description">{selected.description}</p>}
|
||||||
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
|
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
|
||||||
<h3>Cast vote</h3>
|
<h3>Cast vote</h3>
|
||||||
@@ -215,12 +234,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
</section>
|
</section>
|
||||||
{selected.result && <section className="voting-section">
|
{selected.result && <section className="voting-section">
|
||||||
<h3>Result</h3>
|
<h3>Result</h3>
|
||||||
<div className="voting-metrics">
|
<MetricGrid columns={4} spacing="block">
|
||||||
<Metric label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
<MetricCard density="compact" label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
||||||
<Metric label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
<MetricCard density="compact" label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
||||||
<Metric label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
<MetricCard density="compact" label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
||||||
<Metric label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
<MetricCard density="compact" label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<Hash label="Result hash" value={selected.result.result_sha256} />
|
<Hash label="Result hash" value={selected.result.result_sha256} />
|
||||||
</section>}
|
</section>}
|
||||||
<section className="voting-section voting-assurance">
|
<section className="voting-section voting-assurance">
|
||||||
@@ -235,8 +254,7 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</PageScrollViewport>}
|
</PageScrollViewport>}
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
{editing && <VotingBallotDialog
|
{editing && <VotingBallotDialog
|
||||||
open
|
open
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@@ -299,10 +317,6 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
|
||||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Hash({ label, value }: { label: string; value?: string | null }) {
|
function Hash({ label, value }: { label: string; value?: string | null }) {
|
||||||
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
|
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,12 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(250px, 320px) minmax(0, 1fr);
|
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--surface, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-catalogue {
|
.voting-catalogue {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--border-color, #d8dde3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-toolbar,
|
|
||||||
.voting-detail-heading,
|
.voting-detail-heading,
|
||||||
.voting-actions,
|
.voting-actions,
|
||||||
.voting-editor-heading {
|
.voting-editor-heading {
|
||||||
@@ -28,52 +18,18 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-toolbar {
|
|
||||||
min-height: 50px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-list-viewport,
|
.voting-list-viewport,
|
||||||
.voting-detail-viewport {
|
.voting-detail-viewport {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-list-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 52px;
|
|
||||||
padding: 7px 8px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-list-row:hover,
|
|
||||||
.voting-list-row.is-selected {
|
|
||||||
background: var(--hover-bg, rgba(54, 99, 135, 0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-list-row > span:first-child,
|
|
||||||
.voting-option-results > div > span:first-child {
|
.voting-option-results > div > span:first-child {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-list-row small,
|
|
||||||
.voting-option-results small {
|
.voting-option-results small {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--text-muted, #65717e);
|
||||||
}
|
}
|
||||||
@@ -118,23 +74,6 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-metrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-metrics > div {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--border-color, #d8dde3);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-metrics span,
|
|
||||||
.voting-hash span {
|
.voting-hash span {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--text-muted, #65717e);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -200,11 +139,6 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-empty {
|
|
||||||
padding: 24px;
|
|
||||||
color: var(--text-muted, #65717e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-ballot-dialog {
|
.voting-ballot-dialog {
|
||||||
height: min(820px, calc(100vh - 32px));
|
height: min(820px, calc(100vh - 32px));
|
||||||
}
|
}
|
||||||
@@ -256,17 +190,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.voting-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: minmax(160px, 34%) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-catalogue {
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-metrics,
|
|
||||||
.voting-option-row,
|
.voting-option-row,
|
||||||
.voting-elector-row {
|
.voting-elector-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user