Design concept and design system in docs/design/: principles (evidence
first, neutral, fair to the people shown, plain language, time as a
dimension, accessible and private), information architecture, core
patterns and a concept for every page; tokens with checked contrast,
component catalogue, content and accessibility rules.
web:
- neutral ink-indigo accent instead of flag red; party colours only as
small markers next to the name; light/dark theme with a switcher
- stylesheets split into tokens, base, layout, components, print
- new header (search, filter count, language, theme, mobile menu) and
footer; breadcrumbs, page headers, tabs with counts, filter bars that
collapse on phones, pagination, notices and empty states
- claim cards with source meter, legal stage, innocence notice inside
the card and a permalink; claim pages with citation (/angaben/{id})
- profiles with role timeline and key figures; party and organisation
dashboards; vote list with position chips; calendar agenda on phones;
tables that stack into cards; loading and error pages; favicon
backend:
- GET /api/v1/claims/{id} and MCP tool get_claim for permalinks
- parties list: evaluate person_status once instead of per party
(30 s -> 1 s on the sample database, identical result)
- parliament calendar: HTML-decode titles ("84. Sitzung")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
5.1 KiB
TypeScript
124 lines
5.1 KiB
TypeScript
import type {Metadata} from 'next';
|
||
import {notFound} from 'next/navigation';
|
||
import {getTranslations, setRequestLocale} from 'next-intl/server';
|
||
import {PositionChip, ResultBadge} from '@/components/badges';
|
||
import {ProportionBar} from '@/components/charts';
|
||
import {ExternalLink} from '@/components/ExternalLink';
|
||
import {Icon} from '@/components/Icon';
|
||
import {Notice} from '@/components/Notice';
|
||
import {Breadcrumbs, SectionHead} from '@/components/PageHeader';
|
||
import {Link} from '@/i18n/navigation';
|
||
import {api, NotFoundError, type VoteDetail} from '@/lib/api';
|
||
import {formatDate, partyStyle} from '@/lib/format';
|
||
|
||
const POSITIONS = ['for', 'against', 'abstain', 'absent'];
|
||
|
||
type Props = {params: Promise<{locale: string; id: string}>};
|
||
|
||
async function loadVote(id: string): Promise<VoteDetail> {
|
||
try {
|
||
return await api.vote(id);
|
||
} catch (error) {
|
||
if (error instanceof NotFoundError) notFound();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function generateMetadata({params}: Props): Promise<Metadata> {
|
||
const {id} = await params;
|
||
const vote = await loadVote(id);
|
||
return {title: vote.item_title};
|
||
}
|
||
|
||
export default async function VotePage({params}: Props) {
|
||
const {locale, id} = await params;
|
||
setRequestLocale(locale);
|
||
const t = await getTranslations('Votes');
|
||
const vote = await loadVote(id);
|
||
const groups = new Map<string, VoteDetail['individual']>();
|
||
for (const v of vote.individual) {
|
||
const key = v.faction ?? '–';
|
||
groups.set(key, [...(groups.get(key) ?? []), v]);
|
||
}
|
||
const colors = new Map(vote.positions.map((p) => [p.party, p.color]));
|
||
const count = (list: VoteDetail['individual'], position: string) => list.filter((v) => v.position === position).length;
|
||
const dissenters = vote.individual.filter((v) => v.dissent).length;
|
||
|
||
return (
|
||
<article>
|
||
<Breadcrumbs items={[{label: t('title'), href: '/abstimmungen'}, {label: vote.item_ref ?? t('vote')}]} />
|
||
<header className="page-header">
|
||
<div className="page-header-main">
|
||
<p className="eyebrow">
|
||
<Icon name="vote" /> {vote.chamber}{vote.voted_on && <> · {formatDate(vote.voted_on, locale)}</>}
|
||
{vote.roll_call && <> · {t('rollCall')}</>}
|
||
</p>
|
||
<h1>{vote.item_title}</h1>
|
||
<p className="page-header-meta">
|
||
{vote.item_ref && <span className="mono">{vote.item_ref}</span>}
|
||
{vote.stage && <> · {vote.stage}</>}
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="result-banner">
|
||
<ResultBadge result={vote.result} large />
|
||
{vote.url && <ExternalLink href={vote.url} className="ext-link">{t('item')}</ExternalLink>}
|
||
{vote.protocol_url && <ExternalLink href={vote.protocol_url} className="ext-link">{t('protocol')}</ExternalLink>}
|
||
</div>
|
||
|
||
{vote.positions.length > 0 && (
|
||
<section aria-labelledby="factions">
|
||
<SectionHead id="factions" title={t('factions')} />
|
||
<ul className="cluster" style={{listStyle: 'none', padding: 0}}>
|
||
{vote.positions.map((p) => (
|
||
<li key={p.slug}><PositionChip position={p.position} party={p.party} color={p.color} /></li>
|
||
))}
|
||
</ul>
|
||
{!vote.roll_call && <p className="small muted">{t('factionOnly')}</p>}
|
||
</section>
|
||
)}
|
||
{vote.positions.length === 0 && !vote.roll_call && <Notice tone="note">{t('splitVote')}</Notice>}
|
||
|
||
{vote.individual.length > 0 && (
|
||
<section className="section" aria-labelledby="individual">
|
||
<SectionHead id="individual" title={t('individualVotes')} />
|
||
<ProportionBar
|
||
label={t('individualVotes')}
|
||
segments={POSITIONS.map((position) => ({
|
||
key: position, label: t(position as 'for'), value: count(vote.individual, position), className: `seg-${position}`
|
||
}))}
|
||
/>
|
||
{dissenters > 0 && <Notice tone="caution">{t('dissentSummary', {count: dissenters})}</Notice>}
|
||
<ul className="faction-grid">
|
||
{[...groups.entries()].map(([faction, members]) => (
|
||
<li key={faction} className="card">
|
||
<h3>
|
||
<span className="party-tag"><span className="party-dot" style={partyStyle(colors.get(faction))} aria-hidden="true" />{faction}</span>
|
||
<span className="muted small">{POSITIONS.map((p) => count(members, p)).filter(Boolean).join(' / ')}</span>
|
||
</h3>
|
||
<ul className="member-list">
|
||
{members.map((m) => (
|
||
<li key={m.slug}>
|
||
<PositionChip position={m.position} showLabel={false} />
|
||
<Link href={`/personen/${m.slug}?tab=votes`}>{m.full_name}</Link>
|
||
{m.dissent && <span className="badge badge--caution">{t('dissent')}</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
)}
|
||
|
||
{vote.raw_text && (
|
||
<details className="section card">
|
||
<summary className="strong">{t('protocolText')}</summary>
|
||
<blockquote className="small">{vote.raw_text}</blockquote>
|
||
</details>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|