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>
243 lines
11 KiB
TypeScript
243 lines
11 KiB
TypeScript
import type {Metadata} from 'next';
|
||
import {notFound} from 'next/navigation';
|
||
import {getTranslations, setRequestLocale} from 'next-intl/server';
|
||
import {ProportionBar, StatGrid} from '@/components/charts';
|
||
import {ClaimList} from '@/components/ClaimList';
|
||
import {Notice} from '@/components/Notice';
|
||
import {Breadcrumbs, SectionHead} from '@/components/PageHeader';
|
||
import {SocialFeed} from '@/components/SocialFeed';
|
||
import {VoteLegend, VoteList} from '@/components/VoteList';
|
||
import {Link} from '@/i18n/navigation';
|
||
import {api, NotFoundError, type PartyDetail} from '@/lib/api';
|
||
import {formatDate, formatMoney, formatMoneyCompact, formatNumber, partyStyle} from '@/lib/format';
|
||
|
||
const LEVEL_ORDER = ['eu', 'federal', 'state', 'district', 'municipal', 'party', 'other'];
|
||
const POSITIONS = ['for', 'against', 'abstain', 'absent'];
|
||
const RELATED_SHOWN = 12;
|
||
|
||
type Props = {params: Promise<{locale: string; slug: string}>};
|
||
|
||
async function loadParty(slug: string): Promise<PartyDetail> {
|
||
try {
|
||
return await api.party(slug);
|
||
} catch (error) {
|
||
if (error instanceof NotFoundError) notFound();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function generateMetadata({params}: Props): Promise<Metadata> {
|
||
const {slug} = await params;
|
||
const party = await loadParty(slug);
|
||
return {title: party.short_name ?? party.name};
|
||
}
|
||
|
||
export default async function PartyPage({params}: Props) {
|
||
const {locale, slug} = await params;
|
||
setRequestLocale(locale);
|
||
const t = await getTranslations('Parties');
|
||
const tv = await getTranslations('Votes');
|
||
const tl = await getTranslations('Persons');
|
||
const tm = await getTranslations('Money');
|
||
const party = await loadParty(slug);
|
||
const [votes, claims, posts] = await Promise.all([
|
||
api.votes({party: slug, limit: 10}),
|
||
api.claims({party: slug, types: ['donation', 'media_mention', 'legal_proceeding', 'money_flow', 'lobbying'], limit: 20}),
|
||
api.social({party: slug, limit: 6})
|
||
]);
|
||
|
||
const years = [...new Set(party.money_by_year.map((row) => row.year))].sort((a, b) => b - a).slice(0, 8);
|
||
const kinds = [...new Set(party.money_by_year.map((row) => row.kind))];
|
||
const cell = (year: number, kind: string) => party.money_by_year.find((row) => row.year === year && row.kind === kind);
|
||
const totalVotes = party.vote_stats.reduce((acc, s) => acc + s.count, 0);
|
||
const byLevel = LEVEL_ORDER.map((level) => [level, party.members.filter((m) => m.level === level)] as const)
|
||
.filter(([, members]) => members.length > 0);
|
||
|
||
return (
|
||
<article>
|
||
<Breadcrumbs items={[{label: t('title'), href: '/parteien'}, {label: party.short_name ?? party.name}]} />
|
||
<header className="page-header">
|
||
<div className="page-header-main">
|
||
<div className="party-band" style={partyStyle(party.color)} aria-hidden="true" />
|
||
<h1>{party.short_name ?? party.name}</h1>
|
||
{party.short_name && <p className="lede">{party.name}</p>}
|
||
</div>
|
||
</header>
|
||
|
||
<StatGrid
|
||
items={[
|
||
{label: t('statOfficials'), value: formatNumber(party.members.length, locale)},
|
||
{label: t('statFormer'), value: formatNumber(party.former_members.length, locale), href: `/ehemalige?party=${slug}`},
|
||
{label: t('statVotes'), value: formatNumber(totalVotes, locale), href: `/abstimmungen?party=${slug}`},
|
||
{label: t('statDissent'), value: formatNumber(party.dissent.length, locale)}
|
||
]}
|
||
/>
|
||
|
||
<div className="grid-2">
|
||
<section aria-labelledby="vote-stats">
|
||
<SectionHead id="vote-stats" title={t('voteStats')} />
|
||
<ProportionBar
|
||
label={t('voteStats')}
|
||
segments={POSITIONS.map((position) => ({
|
||
key: position,
|
||
label: tv(position as 'for'),
|
||
value: party.vote_stats.find((s) => s.position === position)?.count ?? 0,
|
||
className: `seg-${position}`
|
||
}))}
|
||
/>
|
||
<p className="small muted">{t('voteStatsNote')}</p>
|
||
</section>
|
||
|
||
<section aria-labelledby="money">
|
||
<SectionHead id="money" title={t('money')} more={{href: `/geld?org=${slug}`, label: t('allMoney')}} />
|
||
{years.length ? (
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th scope="col">{t('year')}</th>{kinds.map((kind) => <th key={kind} scope="col" className="num">{tm(`kind.${kind}` as 'kind.other')}</th>)}</tr>
|
||
</thead>
|
||
<tbody>
|
||
{years.map((year) => (
|
||
<tr key={year}>
|
||
<th scope="row">{year}</th>
|
||
{kinds.map((kind) => {
|
||
const row = cell(year, kind);
|
||
return (
|
||
<td key={kind} className="num" title={row?.total ? `${formatMoney(row.total, locale)} · ${row.count}×` : undefined}>
|
||
{row?.total ? formatMoneyCompact(row.total, locale) : row ? `${row.count}×` : '–'}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : <p className="muted">{t('noMoney')}</p>}
|
||
</section>
|
||
</div>
|
||
|
||
{byLevel.length > 0 && (
|
||
<section className="section" aria-labelledby="members">
|
||
<SectionHead id="members" title={`${t('members')} (${formatNumber(party.members.length, locale)})`} />
|
||
{byLevel.map(([level, members]) => (
|
||
<div key={level}>
|
||
<h3>{tl(`level.${level}` as 'level.other')} <span className="muted small">({members.length})</span></h3>
|
||
<ul className="name-list">
|
||
{members.slice(0, 120).map((m) => (
|
||
<li key={`${m.slug}-${m.role}`}>
|
||
<Link href={`/personen/${m.slug}`}>{m.full_name}</Link>{' '}
|
||
<span className="muted">{m.role === 'mp' ? m.body : m.role}{m.region && ` · ${m.region}`}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{members.length > 120 && (
|
||
<p className="small"><Link href={`/personen?party=${slug}&level=${level}`}>{t('allMembers', {count: members.length})}</Link></p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</section>
|
||
)}
|
||
|
||
{(party.finance.length > 0 || party.related.length > 0) && (
|
||
<div className="grid-2 section">
|
||
{party.finance.length > 0 && (
|
||
<section aria-labelledby="finance">
|
||
<SectionHead id="finance" title={t('finance')} />
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead><tr><th scope="col">{t('year')}</th><th scope="col" className="num">{t('income')}</th><th scope="col" className="num">{t('expenses')}</th></tr></thead>
|
||
<tbody>
|
||
{party.finance.map((f) => (
|
||
<tr key={String(f.data.year ?? f.valid_from)}>
|
||
<th scope="row">{String(f.data.year ?? formatDate(f.valid_from, locale))}</th>
|
||
<td className="num">{f.data.total_income ? formatMoney(Number(f.data.total_income), locale) : '–'}</td>
|
||
<td className="num">{f.data.total_expenses ? formatMoney(Number(f.data.total_expenses), locale) : '–'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
)}
|
||
{party.related.length > 0 && (
|
||
<section aria-labelledby="related">
|
||
<SectionHead id="related" title={t('related')} />
|
||
<Notice tone="note">{t('relatedNote')}</Notice>
|
||
<RelatedList orgs={party.related.slice(0, RELATED_SHOWN)} />
|
||
{party.related.length > RELATED_SHOWN && (
|
||
<details>
|
||
<summary className="small strong">{t('allMembers', {count: party.related.length})}</summary>
|
||
<RelatedList orgs={party.related.slice(RELATED_SHOWN)} />
|
||
</details>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid-2 section">
|
||
<section aria-labelledby="former">
|
||
<SectionHead id="former" title={t('formerMembers')} more={{href: `/ehemalige?party=${slug}`, label: t('allFormer')}} />
|
||
<ul className="link-list">
|
||
{party.former_members.slice(0, 12).map((m) => (
|
||
<li key={m.slug}>
|
||
<Link href={`/personen/${m.slug}?tab=media`}>{m.full_name}</Link>{' '}
|
||
<span className="muted xsmall">
|
||
{m.last_office_end && tl('lastOffice', {date: formatDate(m.last_office_end, locale)})}
|
||
{m.last_mention && ` · ${t('lastMention', {date: formatDate(m.last_mention, locale)})}`}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
{party.dissent.length > 0 && (
|
||
<section aria-labelledby="dissent">
|
||
<SectionHead id="dissent" title={t('dissent')} />
|
||
<ul className="link-list">
|
||
{party.dissent.map((d) => (
|
||
<li key={d.slug}>
|
||
<Link href={`/personen/${d.slug}?tab=votes`}>{d.full_name}</Link>{' '}
|
||
<span className="muted xsmall">{t('dissentCount', {count: d.dissenting_votes})}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
)}
|
||
</div>
|
||
|
||
<section className="section" aria-labelledby="votes">
|
||
<SectionHead id="votes" title={t('recentVotes')} more={{href: `/abstimmungen?party=${slug}`, label: tv('title')}} />
|
||
<VoteLegend />
|
||
<VoteList votes={votes} />
|
||
</section>
|
||
|
||
{posts.length > 0 && (
|
||
<section className="section" aria-labelledby="channels">
|
||
<SectionHead id="channels" title={t('channels')} more={{href: `/social?party=${slug}`, label: t('allPosts')}} />
|
||
<SocialFeed posts={posts} />
|
||
</section>
|
||
)}
|
||
|
||
<section className="section" aria-labelledby="claims">
|
||
<SectionHead id="claims" title={t('claims')} />
|
||
<ClaimList claims={claims} showSubject={false} />
|
||
</section>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
async function RelatedList({orgs}: {orgs: PartyDetail['related']}) {
|
||
const t = await getTranslations('Parties');
|
||
return (
|
||
<ul className="link-list">
|
||
{orgs.map((org) => (
|
||
<li key={`${org.claim_type}-${org.slug}`}>
|
||
<Link href={`/organisationen/${org.slug}`}>{org.name}</Link>{' '}
|
||
<span className="badge">{t(`relation.${org.claim_type}` as 'relation.affiliation')}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|