Redesign the web app from a written design concept

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>
This commit is contained in:
simonandClaude Opus 5.5 committed 2026-10-28 22:21:44 +01:00
1 parent 45514624f4
commit 456f9816f4
76 files changed
+7127 -1280

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ next-intl, German by default, English at `/en`. Deployed with Docker Compose on
| Derive facts from stored data | [docs/contributing/enrichers.md](docs/contributing/enrichers.md) |
| API endpoint or MCP tool | [docs/contributing/api.md](docs/contributing/api.md) |
| UI page or new claim type in the UI | [docs/contributing/frontend.md](docs/contributing/frontend.md) |
| How the UI should look and read | [docs/design/README.md](docs/design/README.md), [design-system.md](docs/design/design-system.md) |
| Tests and fixtures | [docs/contributing/testing.md](docs/contributing/testing.md) |
| Why it is built this way | [docs/architecture/02-comparison-and-decisions.md](docs/architecture/02-comparison-and-decisions.md) |
| Tables, claim types, identifier schemes | [docs/data-model.md](docs/data-model.md) |
+2
View File
@@ -31,6 +31,8 @@ sources they trust.
|---|---|
| [docs/architecture/01-design-from-scratch.md](docs/architecture/01-design-from-scratch.md) | The architecture: quality goals, data model, adapter framework, serving, testing, guard-rails |
| [docs/architecture/02-comparison-and-decisions.md](docs/architecture/02-comparison-and-decisions.md) | Why it is built this way (decision records) |
| [docs/design/README.md](docs/design/README.md) | Design concept: principles, information architecture, core patterns, every page |
| [docs/design/design-system.md](docs/design/design-system.md) | Design system: tokens, components, content and accessibility rules |
| [docs/data-model.md](docs/data-model.md) | Tables, claim types, identifier schemes, views |
| [docs/sources.md](docs/sources.md) | Every source with access method, licence/ToS assessment, and what we deliberately don't collect (incl. Telegram/WhatsApp/Signal) |
| [docs/legal.md](docs/legal.md) | Copyright/TDM, GDPR, MedienG §7b (presumption of innocence), defamation |
+7
View File
@@ -165,6 +165,13 @@ def get_claims(
limit=limit, offset=offset)
@app.get("/api/v1/claims/{claim_id}", response_model=s.Claim)
def get_claim(claim_id: int, conn: DB, sources: Sources):
"""One claim with all its evidence - the target of permalinks (callers normally omit the source filter)."""
rows = queries.list_claims(conn, sources=sources, claim_id=claim_id, limit=1)
return not_found(rows[0] if rows else None, "claim")
@app.get("/api/v1/timeline", response_model=list[s.TimelineBucket])
def get_timeline(conn: DB, sources: Sources, person: str | None = None, party: str | None = None):
return queries.timeline_histogram(conn, sources=sources, person=person, party=party)
+8
View File
@@ -124,6 +124,14 @@ def list_claims(
until=_date(until), limit=limit))
@mcp.tool()
def get_claim(claim_id: int) -> str:
"""One claim with all its evidence, e.g. from a permalink (/angaben/<id>) a user shared."""
with connection() as conn:
rows = queries.list_claims(conn, sources=SourceFilter([], 0), claim_id=claim_id, limit=1)
return _dump(rows[0] if rows else {"error": f"claim {claim_id} not found"})
@mcp.tool()
def money_flows(kinds: list[str] | None = None, organization: str | None = None, person: str | None = None,
query: str | None = None, since: str | None = None, until: str | None = None,
+3 -1
View File
@@ -21,6 +21,7 @@ def list_claims(
since: date | None = None,
until: date | None = None,
q: str | None = None,
claim_id: int | None = None,
include_pending: bool = False,
limit: int = 50,
offset: int = 0,
@@ -28,7 +29,7 @@ def list_claims(
source_sql, params = sources.sql("s")
params.update({
"person": person, "party": party, "organization": organization, "claim_types": claim_types, "topics": topics, "since": since,
"until": until, "q": q, "limit": limit, "offset": offset, "include_pending": include_pending,
"until": until, "q": q, "claim_id": claim_id, "limit": limit, "offset": offset, "include_pending": include_pending,
})
return conn.execute(
f"""
@@ -73,6 +74,7 @@ def list_claims(
AND (%(since)s::date IS NULL OR COALESCE(c.occurred_at::date, c.valid_to, c.valid_from, current_date) >= %(since)s)
AND (%(until)s::date IS NULL OR COALESCE(c.occurred_at::date, c.valid_from) <= %(until)s)
AND (%(q)s::text IS NULL OR c.summary_de ILIKE '%%' || %(q)s || '%%')
AND (%(claim_id)s::bigint IS NULL OR c.id = %(claim_id)s)
ORDER BY COALESCE(c.occurred_at, c.valid_from::timestamptz, c.created_at) DESC, c.id DESC
LIMIT %(limit)s OFFSET %(offset)s
""",
+12 -4
View File
@@ -8,13 +8,21 @@ from psycopg import Connection
def list_parties(conn: Connection) -> list[dict]:
return conn.execute(
"""
-- person_status is an expensive view: evaluate it once, not once per party
WITH active AS (
SELECT party_id, count(DISTINCT ps.person_id) AS n
FROM person_status ps, unnest(ps.current_party_ids) AS party_id
WHERE ps.status = 'active'
GROUP BY party_id
)
SELECT o.slug, o.name, o.short_name, o.color, o.logo_url,
(SELECT count(*) FROM person_status ps WHERE ps.status = 'active' AND o.id = ANY(ps.current_party_ids))
AS active_officials,
COALESCE(a.n, 0) AS active_officials,
count(DISTINCT r.person_id) AS total_people
FROM organization o LEFT JOIN role r ON r.party_id = o.id
FROM organization o
LEFT JOIN role r ON r.party_id = o.id
LEFT JOIN active a ON a.party_id = o.id
WHERE o.kind = 'party'
GROUP BY o.id
GROUP BY o.id, a.n
ORDER BY active_officials DESC, total_people DESC, o.name
"""
).fetchall()
+3 -2
View File
@@ -99,6 +99,7 @@ class ParlamentCalendar(Adapter):
name = "parlament.calendar"
description = "Parliament calendar: plenary sessions, committees, hearings, events (tours skipped)"
schedule = "30 2 * * *"
parser_version = 2 # 2: titles are HTML-decoded ("84.&nbsp;Sitzung")
def start(self, ctx: RunContext) -> Iterator[Request]:
yield list_request(600, {}, kind="list")
@@ -106,12 +107,12 @@ class ParlamentCalendar(Adapter):
def parse(self, ctx: RunContext, response: Response) -> Iterator[Event]:
since = str(ctx.params.get("since_year", date.today().year - 2))
for row in list_rows(response.json()):
kind_label = (row.get("Terminart") or "").replace("<BR>", " ")
kind_label = html_to_text(row.get("Terminart"))
starts = next((v for k, v in row.items() if k.startswith("Datum") and k != "Datum Bis"
and isinstance(v, str) and ISO_DATE.match(v)), None)
if not starts or starts[:4] < since or SKIP_TERMIN.search(f"{kind_label} {row.get('Bezeichnung') or ''}"):
continue
title = row.get("Bezeichnung") or kind_label
title = html_to_text(row.get("Bezeichnung")) or kind_label
kind = next((k for pattern, k in TERMIN_KIND if pattern.search(f"{kind_label} {title}")), "public_event")
chamber = {"Nationalrat": "NR", "Bundesrat": "BR"}.get(row.get("Gremium") or "")
ends = next((v for k, v in row.items() if k.startswith("Datum Bis") and isinstance(v, str)
+16
View File
@@ -142,3 +142,19 @@ def test_firmenbuch_number():
assert firmenbuch_number("FN 306527 a") == firmenbuch_number("FN306527A") == firmenbuch_number("306527a") == "306527a"
assert firmenbuch_number("ATU12345678") is None
def test_parlament_calendar_titles_are_html_decoded():
import json
from politrack.core.runner import make_response, parse_offline
from politrack.sources.parlament.news import ParlamentCalendar
body = {"header": [{"label": "Datum"}, {"label": "Terminart"}, {"label": "Bezeichnung"}, {"label": "Gremium"},
{"label": "Link"}, {"label": "WENTRY_ID"}],
"rows": [["2026-06-10T09:00:00", "Plenarsitzung<BR>NR", "84.&nbsp;Sitzung des Nationalrates", "Nationalrat",
"/termin/1", "1"]]}
events = parse_offline(ParlamentCalendar(), make_response("https://www.parlament.gv.at/x", json.dumps(body).encode(), kind="list"),
params={"since_year": 2026})
assert [e.title for e in events] == ["84. Sitzung des Nationalrates"]
assert events[0].description == "Plenarsitzung NR"
+1 -1
View File
@@ -59,7 +59,7 @@ parsers, archive-first, per-publisher legal policy) – see ADR-20.
## Interfaces for other agents
1. **MCP** – `http://<host>:8001/mcp`: `search`, `find_person`, `get_person`, `person_network`,
`list_parties`, `get_party`, `find_organization`, `get_organization`, `list_claims`, `money_flows`,
`list_parties`, `get_party`, `find_organization`, `get_organization`, `list_claims`, `get_claim`, `money_flows`,
`social_posts`, `wikipedia_edits`, `calendar`, `person_votes`, `former_politicians`, `list_topics`.
2. **REST** – `http://<host>:8000/api/docs` (OpenAPI 3).
3. **SQL** – role `politrack_reader`; start with the view `claim_with_evidence`; `\d+ claim` shows the
+55 -22
View File
@@ -1,17 +1,25 @@
# Extending the web app
Next.js 15 (App Router, React Server Components) with next-intl (German default, English). Pages fetch
from the API on the server; there is almost no client-side JavaScript.
from the API on the server; there is almost no client-side JavaScript. How it should look and why:
[design/README.md](../design/README.md) (concept) and [design/design-system.md](../design/design-system.md)
(tokens, components, content rules).
```
web/
messages/de.json, en.json all UI text (keys must exist in both)
src/app/[locale]/<route>/page.tsx pages (German route names: /personen, /parteien, /geld …)
src/components/ shared server components
src/app/[locale]/layout.tsx frame: header, footer, theme, stylesheets
src/app/[locale]/loading.tsx, error.tsx, not-found.tsx
src/app/icon.svg favicon
src/components/ shared components (catalogue: design-system.md §4)
src/components/nav.ts site structure for header, menu and footer
src/styles/ tokens, base, layout, components, print
src/lib/api.ts typed API client (server-side only; reads the filter cookie)
src/lib/api-types.ts GENERATED from the backend's response models – do not edit
src/lib/format.ts dates, money, localized names
src/lib/prefs.ts the user's source/topic filter (cookie)
src/lib/format.ts dates, periods, numbers, money, party colours
src/lib/prefs.ts the visitor's source/topic filter and theme (cookies)
src/lib/roles.ts role labels
```
## Types come from the backend
@@ -25,34 +33,54 @@ cd ../web && npm run typecheck
`api.ts` re-exports the generated types (`Claim`, `MoneyFlow`, …) and adds one function per endpoint.
`api.claims()`, `api.money()`, `api.social()` … automatically apply the visitor's source filter
(`withPrefs`), so every list respects "which sources do I trust".
(`withPrefs`), so every list respects "which sources do I trust". `api.claim(id)` deliberately does not:
a permalink shows the same to everyone.
## Adding a page
1. Create `src/app/[locale]/<route>/page.tsx` – an async server component:
1. Create `src/app/[locale]/<route>/page.tsx` – an async server component built from the shared parts:
```tsx
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'MyPage'});
return {title: t('title')};
}
export default async function Page({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
const sp = await searchParams;
const t = await getTranslations('MyPage');
const rows = await api.something({...});
return <>…</>;
const page = Math.max(1, Number(sp.page) || 1);
const rows = await api.something({kind: sp.kind, limit: PAGE, offset: (page - 1) * PAGE});
return (
<>
<PageHeader title={t('title')} lede={t('intro')} />
<FilterBar id="mypage" active={countActive(sp.kind)} resetHref="/meine-seite">
<Field label={t('kind')}><select name="kind" defaultValue={sp.kind ?? ''}>…</select></Field>
</FilterBar>
{rows.length === 0 ? <EmptyState>{t('none')}</EmptyState> : <ul className="grid-cards">…</ul>}
<Pagination pathname="/meine-seite" query={sp} page={page} hasNext={rows.length === PAGE} />
</>
);
}
```
Filters are plain `<form method="get">` – shareable URLs, no client state.
Filters are plain GET forms – shareable URLs, no client state.
2. Add the texts to **both** `messages/de.json` and `messages/en.json`.
3. Link it from the navigation (`src/app/[locale]/layout.tsx`) or the home page's "Entdecken" cards.
3. Add it to the site structure in `src/components/nav.ts` (primary, "Mehr" or a footer group) or link it
from the home page's "Entdecken" cards.
4. Page modules may only export Next.js page fields (`default`, `generateMetadata`, …); keep helpers and
constants module-local or in `src/components`.
5. Check it at 390 px and in both themes (design-system.md §7).
## Showing a new claim type
Claims render through `ClaimList`; type-specific details come from the registry in
Claims render through `ClaimList` → `ClaimCard`; type-specific details come from the registry in
`src/components/ClaimDetails.tsx`:
```tsx
const ambassador: Renderer = (claim, {t}) => (
<p className="claim-detail small">{t('country')}: {String(claim.data.country ?? '')}</p>
<p className="claim-detail">{t('country')}: {String(claim.data.country ?? '')}</p>
);
const DETAILS: Partial<Record<ClaimType, Renderer[]>> = {
@@ -61,21 +89,26 @@ const DETAILS: Partial<Record<ClaimType, Renderer[]>> = {
};
```
Also add the label under `Claims.type.<claim_type>` in both message files, and (if it should appear) the
type to the person page's `TABS`.
Also add an icon to `TYPE_ICONS` in `src/components/badges.tsx`, the label under
`Claims.type.<claim_type>` in both message files, and (if it should appear) the type to the person page's
`TABS`.
## Non-negotiables in the UI
* **Every fact shows its sources** (the "Quellen" disclosure under each claim, `[source]` links in
tables). Don't render a claim without its evidence.
* **Presumption of innocence:** `needsInnocenceNotice()` in `ClaimList.tsx` decides; never remove the
notice for anything but an undisputed final conviction.
* **Connections are not accusations:** pages that show networks or money of connected organisations
* **Every fact shows its sources** (the evidence disclosure under each claim, source chips in tables).
Don't render a claim without its evidence.
* **Presumption of innocence:** `needsInnocenceNotice()` in `ClaimList.tsx` decides; the notice stays inside
the claim card. Never remove it for anything but an undisputed final conviction.
* **Connections are not accusations:** sections that show networks or money of connected organisations
carry the note (`Network.note`, `Organizations.connectionNote`).
* **Neutral colours:** party colours only through `partyStyle()` as dots and thin bars, next to the name.
* Unverified social accounts are marked (Wikidata-only handles).
* No third-party scripts, fonts, embeds or trackers.
## Styling
One global stylesheet (`src/app/globals.css`) with CSS variables for light/dark mode. Reuse the existing
classes (`claims`, `claim-detail`, `chip`, `tag`, `notice`, `grid-2`, `compact`, `num`, `muted`, `small`)
before adding new ones.
Global CSS with design tokens, one section per component in `src/styles/components.css`. Use tokens
(`var(--ink-2)`, `var(--space-4)`) instead of raw values, reuse the components and layout utilities
listed in [design-system.md](../design/design-system.md), and add a new section (and a row in its
component table) when you create a component. A new token goes into all three blocks of `tokens.css`
(light, dark by preference, dark by choice).
+478
View File
@@ -0,0 +1,478 @@
# Politrack design concept
How Politrack looks, reads and behaves, and why. This is the concept: goals, principles, information
architecture, the core patterns and a concept for every page. The companion
[design-system.md](design-system.md) is the implementation reference: tokens, components, CSS
conventions and recipes for contributors.
Items marked **(later)** are designed here but not built yet; everything else is implemented in `web/`.
## 1. Goals
Politrack is a public record of what Austrian politicians do, where every fact links to its source.
The interface has four jobs, in this order:
1. **Make every fact checkable.** A reader is never more than one click from the document behind a
statement on the screen.
2. **Be fair to everyone shown.** The same layout, wording and weight for every person and party; the
presumption of innocence and the limits of the data are visible where they matter, not in a footer.
3. **Answer common questions fast, on a phone.** "Who is this?", "How did they vote?", "Who gave money to
whom?" in a few seconds, with no app and no account.
4. **Support deep research on a desk.** Dense tables, filters in the URL, time ranges, cross-links between
people, organisations and money, and stable links for citing.
## 2. Audiences and their tasks
| Audience | Context | Top tasks | What the UI must provide |
|---|---|---|---|
| Interested citizens | Phone, linked from news or social media, a few minutes | Look up a politician, a party's votes, donations | Search first, plain language, clear summaries, a readable profile on a small screen |
| Journalists and researchers | Desktop, long sessions, need to cite | Follow money between organisations, build a timeline, check a claim | Filters and date ranges in the URL, dense tables, permalinks per fact, source details, consistent numbers |
| NGOs and activists | Desktop and phone, recurring | Monitor topics and people | Topic filters, calendar, (later) alerts |
| The people profiled and their staff | Desktop | Check what is shown about them | Fair presentation, visible sources, (later) a corrections form |
| AI agents | No UI (MCP server, REST API) | Retrieve facts with evidence | Mentioned in the footer and methodology; the UI never shows anything the API doesn't |
## 3. Principles
Each principle comes with the rules that make it concrete.
**P1 Evidence first.** The atom of the interface is the *claim card*: a one-sentence summary plus its
sources. Rules: the number of sources is always visible; the source list is one click away; every
external link opens the original; quotes are shown as quotes (marked, attributed); there is no UI
element that states a fact without a claim behind it.
**P2 Neutral by design.** Rules: the interface accent is a colour no Austrian party uses (an ink indigo,
not the flag red, which reads as SPÖ red); party colours appear only as small identity markers (a dot or
a thin bar) and always next to the party's name, never as large backgrounds; every party and person gets
the same layout; lists are ordered by neutral criteria (date, size, alphabet); copy uses reporting verbs
("laut", "gemeldet", "berichtet") instead of judgement.
**P3 Show certainty honestly.** One vocabulary for how sure we are (see §6.3), shown the same way
everywhere: single vs. multiple sources, disputed facts, unverified accounts, unconfirmed status,
publication date vs. event date.
**P4 Fairness for legal matters.** The procedural stage is the headline of a legal claim; "Es gilt die
Unschuldsvermutung" sits inside the claim card, not in a footnote; legal claims are styled in a neutral
caution tone, never in alarm red; no portraits inside legal claim cards or feeds of allegations; connections
between people and organisations carry the note that a connection is not an accusation; profile overviews
highlight work (votes, speeches, offices), not allegations.
**P5 Plain language, German first.** German is the default at `/`, English at `/en`. Technical terms
(namentliche Abstimmung, Klub, Ministerrat, Diversion) are explained where they appear. Long German
compounds must not break layouts: hyphenation is on and long tokens may wrap.
**P6 Time is a first-class dimension.** Every list can be narrowed to a date range, profiles have a
timeline, a calendar shows what happened on a day, and "search in time" lists everything in a period.
Dates say what they are ("Datum = Veröffentlichung der Quelle" where we only know the publication date).
**P7 Fast, robust, accessible, private.** Server-rendered pages that work without JavaScript; WCAG 2.2
AA; system fonts; no third-party scripts, fonts, embeds or trackers; print stylesheets that keep source
URLs.
**P8 Shareable and citable.** Filters live in the URL; every claim has a permalink page with a ready-made
citation; page titles name the person or topic.
## 4. Brand and tone
**Name and mark.** *politrack* in lowercase, set in the system font at heavy weight. The mark is a pair of
square brackets around a dot: the citation bracket of a footnote with the piece of evidence inside. It is
the favicon too. Tagline: *Österreichs Politik, belegt.*
**Colour stance.** Near-monochrome "paper and ink" neutrals carry the interface. One accent, an ink indigo,
marks interaction (links, focus, active navigation, magnitude bars). A highlighter yellow marks quoted
evidence and search hits. Semantic colours (positive, negative, caution) appear only in small badges and
always with an icon and a word, never as colour alone. Party colours follow the rules in P2.
**Voice.** Sachlich, precise, friendly. We address readers with "du" (as the existing copy does) and use
gender-inclusive forms with a colon (Politiker:innen). We describe what a source says, with the source:
"Laut Rechnungshof spendete …", not "X kaufte sich Einfluss". Numbers are exact where the source is exact
and labelled as reported ("Beträge wie gemeldet").
## 5. Information architecture
### Sitemap
```
/ Start: search, key figures, latest facts, explore
/suche Search (full text and "search in time": a date range without a query)
/personen People (filters: party, level, status)
/personen/{slug} Profile with tabs: overview, timeline, votes, travel & meetings,
statements, social media, money & network, media & career, legal
/ehemalige Former politicians: what they do now
/parteien Parties
/parteien/{slug} Party: officials, votes, money, finances, affiliated organisations, channels
/organisationen Organisations (companies, associations, authorities, media, lobbies)
/organisationen/{slug} Organisation: money in and out, counterparties, connected people
/geld Money explorer: donations, contracts, ads, subsidies, party funding
/abstimmungen Votes (faction votes; roll calls)
/abstimmungen/{id} Vote: result, factions, individual votes, dissent
/kalender Calendar: sessions, committees, hearings, Council of Ministers, trips, meetings
/social Public posts from politicians' and parties' channels
/wikipedia Wikipedia edits from parliament and government networks
/angaben/{id} Claim permalink: one fact with all its sources and a citation
/einstellungen My filters: trusted sources, topics
/methodik Methodology, statuses, legal stages, sources and their terms
```
Route names are German in both languages (`/en/personen/…`): one URL scheme, links stay valid when
switching language.
### Navigation
* **Header**, primary: Personen, Parteien, Abstimmungen, Geld, Organisationen, Kalender; **Mehr**:
Social Media, Wikipedia-Bearbeitungen, Ehemalige, Methodik & Quellen. Utilities: a search field,
*Meine Filter* with the number of active filters, language, light/dark theme. Below 1024 px the
navigation collapses into a menu (a native disclosure, no JavaScript) and the search field becomes a
search button.
* **Footer**: the full sitemap in three groups (Menschen, Entscheidungen, Geld & Einfluss), the
principles in three lines, links for developers and agents.
* **Breadcrumbs** on every detail page (Start › Personen › Name).
* **Cross-links everywhere**: every person, party and organisation name is a link; money rows link both
sides; lists on profiles end with a "Alle …" link into the explorer, pre-filtered.
### URL conventions
All filters are query parameters on plain GET forms (`/geld?kind=donation&since=2025-01-01`), so every view
can be bookmarked and shared. Personal source/topic preferences are the only state kept outside the URL
(a cookie), and the header always shows when they are active.
## 6. Core patterns
### 6.1 The claim card
```
┌──────────────────────────────────────────────────────────────────────┐
│ ◇ SPENDE · 12. März 2026 · Österreichische Volkspartei ●●○ 2 │ type, date, subject, sources
│ │
│ Die Muster GmbH spendete der ÖVP 25.000 €. │ summary (the fact)
│ 25.000 € · Muster GmbH → ÖVP · 2025 │ type-specific details
│ [Wirtschaft] [Parteifinanzen] │ topics
│ ▸ 2 Belege Permalink ↗ │ evidence disclosure
└──────────────────────────────────────────────────────────────────────┘
```
* The header row is metadata in small type: type label with icon, date, subject (when the list mixes
subjects), and the **source meter** on the right.
* The summary is the largest text in the card. In English the English summary is used when available.
* Type-specific details (amounts, counterparts, platforms, file numbers) come from a registry, one renderer
per claim type.
* The evidence disclosure lists each source with its publisher, source kind (Amtlich, Medien, …), title,
date, stance (supports / mentions / contradicts) and quote. Quotes are set as block quotes with the
highlighter mark.
* A **permalink** opens the claim page (§7.17).
* Disputed claims get a caution edge and the word "umstritten". Claims awaiting review are never public;
if one is shown (admin views) it carries "Noch nicht redaktionell geprüft".
### 6.2 Evidence and corroboration
The source meter shows up to three dots: one filled dot = one source ("einfach belegt"), two or more
filled = "mehrfach belegt" with the number. A disputed claim shows a caution triangle and the word
"umstritten" instead. A tooltip gives the full wording ("bestätigt durch 3 unabhängige Quellen"). The meter
never stands alone: the number is always written next to it.
### 6.3 Certainty vocabulary
| Concept | Values (German UI) | Shown as |
|---|---|---|
| Claim status | einfach belegt · mehrfach belegt · umstritten | source meter; caution edge for umstritten |
| Person status | Aktiv · Ehemalig · Ohne Amt (Parteimitglied) · Verstorben | status badge; "Aktiv" without an official confirmation adds "nicht amtlich bestätigt" |
| Social account | bestätigt · nicht bestätigt | unconfirmed accounts get a dashed badge and a tooltip (source: Wikidata only) |
| Review | Noch nicht redaktionell geprüft | caution notice (admin views only) |
| Dates | Ereignisdatum · Veröffentlichungsdatum | footnote on calendar and trips: "Datum = Veröffentlichungsdatum der Quelle" |
| Amounts | wie gemeldet · Kategorie | exact amount, or the reported category ("über 50.000 €") |
### 6.4 Legal matters
```
┌──────────────────────────────────────────────────────────────────────┐
│ ⚖ VERFAHREN · 3. Februar 2026 · Name ●○○ 1 │
│ [Ermittlungsverfahren] § 302 StGB · WKStA │ stage first, as a badge
│ Summary of what the source reports … │
│ ⓘ Es gilt die Unschuldsvermutung. │ inside the card
│ ▸ 1 Beleg │
└──────────────────────────────────────────────────────────────────────┘
```
The notice is shown for every stage except an undisputed final conviction (including acquittals,
dropped proceedings and diversion, which confirm there is no finding of guilt). Automatically detected
legal claims stay in review until a human approves them. Profiles count legal claims only on their tab,
never in the "at a glance" figures.
### 6.5 Money
* Amounts in euro with locale formatting (`25.000 €`, `€25,000`); large figures in summaries and bar
lists are compact (`1,2 Mio. €`) with the exact value in the tooltip; tables show exact values.
* Direction is always explicit: **Von → An**. Both sides are links when the organisation is known.
* Every amount says what kind of money it is (Spende, öffentlicher Auftrag, Regierungsinserat, Förderung,
Parteienförderung, politische Werbung, Kredit). Different kinds are never summed into one figure
without a label.
* "Beträge wie gemeldet" and the list of registers sit under every money table.
* Money of organisations a person is *connected* to (side jobs, career) is labelled as the organisation's
money and carries the connection note.
### 6.6 Votes
* A vote shows date, item title, reference, stage, chamber, result and the position of every faction as
**position chips**: party dot + short name + position icon (✓ dafür, ✕ dagegen, – Enthaltung,
○ abwesend), with a tinted background per position.
* Roll calls are marked ("namentlich"); they show individual votes grouped by faction with counts, and
members who voted against their faction are marked "gegen die eigene Fraktion".
* On a person's vote list the position says whether it is the faction's vote or the person's own vote,
because most Austrian votes are recorded per faction only. The note explaining this sits above the list.
* *Say vs. vote*: when the person spoke on the item, a link to the speech appears under the vote.
### 6.7 Time
* Dates in lists: `12. März 2026` (de-AT) / `12 Mar 2026` (en-GB). Months in charts: `03/2026`.
* The **activity histogram** (claims per month) on profiles links each bar to that month.
* **Date range** fields on every list that supports them; with no query, search becomes "search in
time" and lists everything in the range.
* The **role timeline** on a profile draws offices and memberships as bars on a year axis.
### 6.8 Filters
Two kinds, never mixed up:
* **Page filters** (party, level, kind, date …) live in the URL. Active filters are visible in the form;
a reset link clears them. On phones the filter form is collapsed behind "Filter" with the number of
active filters.
* **My filters** (trusted sources, minimum trust, topics) are stored in a cookie and apply to every list.
When they are active, the header badge shows the count and lists show a notice with a link to change
them, so nobody is surprised by missing data.
### 6.9 Connections
Wherever the UI shows that a person is connected to an organisation (side job, career, meeting, party)
or shows the organisation's money, the note "Eine Verbindung ist kein Vorwurf." is part of the section.
### 6.10 Social posts
Posts are shown as text cards with platform, author, date and a link to the original; no embeds, no
third-party images or scripts. Accounts that only Wikidata attributes to a person are marked "Konto nicht
bestätigt". The collection policy (official APIs only; no Facebook/Instagram scraping; no WhatsApp,
Signal or private Telegram groups) is stated on the page.
### 6.11 Empty, loading and error states
* **Empty**: an icon, one sentence that says why ("Keine Angaben mit deinen Quellen- und Themenfiltern.")
and the most likely next step (reset filters, broaden the date range).
* **Loading**: skeleton blocks in the page's layout, no spinners.
* **Error**: what happened in one sentence, a retry button, a link home.
## 7. Page concepts
Every page uses one of four templates: **index** (header, filters, results, pagination, source note),
**profile** (identity header, key figures, tabs, main column plus aside), **detail** (header, verdict,
breakdown) and **prose** (methodology).
### 7.1 Start (`/`)
Purpose: orient, search, show that the data is alive.
```
┌ hero ───────────────────────────────────────────────────────────────┐
│ Politik in Österreich – nachvollziehbar. │
│ Lede: what Politrack is, "jede Angabe mit Quelle". │
│ [ Person, Thema oder Stichwort … ] [Suchen] │
│ Schnellzugriff: Parteispenden · Namentliche Abstimmungen · Inserate │
└──────────────────────────────────────────────────────────────────────┘
[ 1.234 Personen ] [ 56.789 Angaben ] [ 1.210 Abstimmungen ] [ 9.876 Geldflüsse ] Stand …
┌ Neueste Angaben (claim feed) ──────────┐ ┌ aside ───────────────────┐
│ claim card … │ │ Parteien (dot, name, n) │
│ claim card … │ │ Letzte Abstimmungen │
└─────────────────────────────────────────┘ └──────────────────────────┘
Entdecken: 6 cards with icon, title, one line (Geld, Organisationen, Social, Wikipedia, Ehemalige, Kalender)
So arbeitet Politrack: Belege · Unabhängige Bestätigung · Unschuldsvermutung → Methodik
```
### 7.2 Search (`/suche`)
A large search field with optional date range. Results are cards with type label, date, subject and a
snippet with the query terms highlighted; document hits (news, releases) show the publisher. Without a
query but with a date range, the page lists all claims in that period ("search in time"). Empty states
suggest fewer words or a wider range.
### 7.3 People (`/personen`)
Index template. Filters: name, party, level (EU, Bund, Land, Gemeinde, Partei), status (default: aktiv).
Results are **person cards** in a responsive grid: portrait or initials, name, current roles (two lines
max), party tags, and the status for non-active people. Pagination at the bottom.
### 7.4 Person profile (`/personen/{slug}`)
```
Start › Personen › Name
┌──────┐ [Aktiv] Profile elsewhere:
│ │ Vorname Nachname Parlament ↗ Wikidata ↗ EP ↗
│ foto │ Abgeordnete zum Nationalrat · ● ÖVP
│ │ * 3. Mai 1970 · Kanäle: [Bluesky] [Telegram*]
└──────┘
[Überblick] [Zeitleiste 312] [Abstimmungen 120] [Reisen & Treffen 4] [Aussagen 58] [Social Media]
[Geld & Netzwerk 6] [Medien & Werdegang 40] [Rechtliches 1]
Overview:
┌ main ─────────────────────────────────────┐ ┌ aside ───────────────────────┐
│ Laufbahn (role timeline on a year axis) │ │ Auf einen Blick │
│ Neueste Angaben (claim cards) │ │ 120 Abstimmungen · 58 Reden │
│ │ │ Funktionen & Mandate (list, │
│ │ │ each with its sources) │
└───────────────────────────────────────────┘ └──────────────────────────────┘
```
* Tabs are links (`?tab=votes`), so every tab is shareable; each shows its claim count.
* **Zeitleiste**: activity histogram, date range, all claims in the range.
* **Abstimmungen**: the faction-vote note, then the vote list with position, own/faction vote, dissent and
say-vs-vote.
* **Geld & Netzwerk**: connected organisations (via side job, career, meeting, party) with their money,
the connection note, the organisations' money flows, then money-related claims.
* **Rechtliches**: legal claims only, each with stage and the innocence notice.
* "At a glance" counts work: votes, speeches and statements, questions, side jobs, appearances, media
reports; not legal matters.
### 7.5 Former politicians (`/ehemalige`)
Index template ordered by latest media coverage. Each card: name, status, last role, last office end,
latest headline with source link and date, latest career entry. Party filter.
### 7.6 Parties (`/parteien`)
Cards with a thin top bar in the party colour, short name, full name, active officials and people
tracked. Ordered by the number of active officials.
### 7.7 Party (`/parteien/{slug}`)
```
──────── party colour bar ────────
Start › Parteien › ÖVP
ÖVP Österreichische Volkspartei
[ 312 Funktionsträger:innen ] [ 1.020 Ehemalige ] [ 830 Abstimmungen ] [ 4 abweichende Mandatar:innen ]
┌ Stimmverhalten (proportion bar) ──┐ ┌ Geld nach Jahr und Art (table) ─────┐
└───────────────────────────────────┘ └─────────────────────────────────────┘
Funktionsträger:innen grouped by level (EU · Bund · Land · Gemeinde), multi-column
┌ Rechenschaftsberichte ────────────┐ ┌ Nahestehende Organisationen ────────┐
┌ Ehemalige ────────────────────────┐ ┌ Abweichendes Stimmverhalten ────────┐
Letzte Abstimmungen · Kanäle der Partei · Belegte Angaben
```
### 7.8 Organisations (`/organisationen`)
Index template with a table: name, kind badge, paid, received (compact amounts, exact in tooltip). On
phones the table stacks into cards. Filters: name, kind.
### 7.9 Organisation (`/organisationen/{slug}`)
Header with kind badge and parent organisation; identifiers (Firmenbuch, ZVR, UID, Wikidata, EU
transparency register) in the aside with links where a public register page exists. Key figures (received,
paid, counterparties). Counterparties as two bar lists ("zahlte an", "erhielt von"). Connected people with
the connection note. Money flow table, then all claims. Parties redirect to their party page.
### 7.10 Money (`/geld`)
Explorer template. Filters: kind, name, date range, minimum amount. Unfiltered, the page opens with two
bar lists, largest payers and largest recipients, each row labelled with the kind of money. Then the money
table: date, kind, from, to, amount, sources. Footer: registers used and "Beträge wie gemeldet".
### 7.11 Votes (`/abstimmungen`) and vote (`/abstimmungen/{id}`)
The index is a list of vote rows: date, title, reference, roll-call marker, result badge, position chips
per faction. Filter: roll calls only. The vote page shows the verdict as a large result badge, links to the
item and the protocol, faction positions, and for roll calls a proportion bar over all individual votes and
the members grouped by faction with dissent marked. The protocol wording sits in a disclosure.
### 7.12 Calendar (`/kalender`)
Month grid (Monday first) with previous/next month as header actions. Each event shows its kind (colour
stripe + label), title (linked to the source) and participants. Days with only claim activity link to
"search in time" for that day. Today is highlighted. On phones the grid becomes an agenda: only days with
events, in order. A legend explains the kinds; the footnote states the date precision.
### 7.13 Social media (`/social`)
Feed of post cards in one or two columns. Filters: platform, party. Each card: platform badge, author
(person or party channel), date, title, text (clamped to a few lines), "Original ansehen ↗", unconfirmed
marker. The collection policy closes the page.
### 7.14 Wikipedia edits (`/wikipedia`)
The framing comes first: a notice that an edit from a network says nothing about which person made it.
Then a table: date, network (organisation), article (linked to the diff), size change, edit comment.
### 7.15 My filters (`/einstellungen`)
Two sections. **Quellen**: grouped by kind (Amtlich, NGO, Medien, Datenbanken, Soziale Medien,
Community, Abgeleitet); each source is a switch row with name, trust value as a small meter, and its terms
in the tooltip; a minimum-trust slider below. **Themen**: topic chips that toggle; none selected = all.
A sticky action bar with Speichern, Zurücksetzen and the saved status. Stored in a cookie only.
### 7.16 Methodology (`/methodik`)
Prose template with a table of contents: how claims and evidence work, the status vocabulary with live
badges, the legal stages glossary, the sources table (publisher, licence, access method, trust meter, terms
notes), what we deliberately don't collect, corrections.
### 7.17 Claim permalink (`/angaben/{id}`)
One fact on its own page: type, date, subject, the summary as the title, details, legal notice where
applicable, all sources expanded with quotes, and a citation box:
`Politrack: „{Zusammenfassung}“. Angabe #{id}, abgerufen am {Datum}. {URL}`. The page ignores personal
source filters, so a shared link shows the same content to everyone.
### 7.18 Not found and errors
Not found: an empty state with the search field and links to Personen, Parteien and Start. Errors: one
sentence, "Erneut versuchen", link home.
## 8. Responsive behaviour
| Width | Behaviour |
|---|---|
| < 640 px | one column; tables stack into labelled cards; filter forms collapse; role timeline labels above the bars; tabs scroll horizontally |
| < 768 px | the calendar becomes an agenda (only days with events) |
| 640–1023 px | two-column card grids; tables scroll horizontally inside their container; menu still collapsed |
| ≥ 1024 px | full header navigation with search field; profile pages get the aside column; two-column sections |
| ≥ 1280 px | content width caps at 76 rem; more cards per row |
## 9. Accessibility (WCAG 2.2 AA)
* Contrast ≥ 4.5:1 for text and ≥ 3:1 for UI parts in both themes (token values are chosen and checked
for it, see the design system).
* Never colour alone: positions, results, statuses and party identity always have text, and an icon or
a shape where possible.
* Keyboard: skip link, visible focus ring on everything, tabs and disclosures are native links and
`details` elements, no keyboard traps.
* Structure: one `h1` per page, headings in order, landmarks (header, nav, main, footer), `aria-current`
for the active page and tab, tables with header cells, charts with text equivalents.
* Language: `lang` on the document; English summaries are marked when shown in the German UI.
* Motion: no animation beyond short hover transitions; `prefers-reduced-motion` removes them.
* Targets at least 24 × 24 px; form fields have visible labels.
## 10. Privacy and performance
* No third-party requests from the page itself: system fonts, no analytics, no embeds, no CDNs.
Portraits and logos still come from Wikimedia and the parliament; they load lazily with
`referrerpolicy="no-referrer"`. **(later)** an image proxy so visitors' IP addresses never reach
third parties.
* Server-rendered HTML; client JavaScript only for the filter settings, the theme switch, the language
switch and active-link marking.
* Budget: under 100 kB of CSS and JavaScript per page (compressed), first content without waiting for
scripts.
## 11. Before going public
Not design work, but the design depends on them; flagged here so they are not forgotten:
* **Impressum and Offenlegung** (§ 5 ECG, § 25 MedienG) and a **privacy policy**: footer links and a
prose page each.
* A **corrections and right-of-reply** path (§ 9 MedienG): a contact on the methodology page and, later, a
form linked from every claim page.
* The X deletion sync (see the roadmap) before X data is shown publicly.
## 12. Later
* Claim permalinks are implemented; **(later)** a "Fehler melden" button on them.
* Compare two people or parties side by side (votes, money, statements).
* Topic pages (`/themen/{slug}`): everything on one topic across people and parties.
* Alerts (e-mail or RSS) for people, parties and topics.
* A review queue for editors in the web UI (today `politrack review` on the command line).
* A map of trips and appearances.
* Share images (Open Graph) generated per profile and claim.
* CSV export of every table.
+305
View File
@@ -0,0 +1,305 @@
# Politrack design system
The implementation reference for the web app: tokens, components, CSS conventions, content rules and
recipes. The concept behind it (goals, principles, page concepts) is in [README.md](README.md); how to add
pages and claim types step by step is in [contributing/frontend.md](../contributing/frontend.md).
## 1. Architecture
```
web/src/styles/
tokens.css design tokens (custom properties), light + dark
base.css element defaults: typography, links, tables, forms, buttons, text utilities
layout.css page frame (header, menu, footer) and layout utilities
components.css one section per component in src/components
print.css print: content only, link targets written out
web/src/components/ server components (a few client components, marked 'use client')
web/src/app/icon.svg favicon (the brand mark)
```
All five stylesheets are imported once, in order, by `src/app/[locale]/layout.tsx`.
* **Plain global CSS** with custom properties: no CSS framework, no CSS-in-JS, no npm dependency beyond
Next.js, React and next-intl. The UI must keep working where npm is slow or blocked.
* **Class names**: one block per component in kebab-case (`.claim`, `.claim-head`, `.vote-row`);
variants with `--` (`.badge--positive`, `.notice--legal`); states with `is-` (`.is-current`,
`.is-disputed`); `aria-current` for the active link or tab.
* **Only tokens** for colour, spacing, radius and type. The exceptions are deliberate: party colours
(from the data), platform brand colours, and the favicon.
* **Server first.** Pages and most components are async server components. Client components exist only
where the browser must act: `ThemeSwitcher`, `LocaleSwitcher`, `NavLink`, `SettingsForm`, the error page.
* **No JavaScript needed** for navigation, filters, tabs, disclosures or the mobile menu: links, GET forms,
`<details>` and one checkbox toggle.
## 2. Tokens
### Colour
| Token | Light | Dark | Use |
|---|---|---|---|
| `--paper` | `#f6f5f1` | `#131311` | page background |
| `--surface` | `#ffffff` | `#1b1a17` | cards, panels, tables, header |
| `--surface-2` | `#efede7` | `#24231f` | subtle fills: chips, table heads, compact stats |
| `--surface-3` | `#e4e1d9` | `#2e2c27` | avatar placeholder, skeleton highlight |
| `--line` | `#dcd8cf` | `#36332d` | borders and rules |
| `--line-strong` | `#b9b3a6` | `#57534a` | emphasised rules, decorative outlines |
| `--line-field` | `#878073` | `#7d786f` | form field borders, switch track (≥ 3:1) |
| `--ink` | `#1c1b18` | `#ecebe6` | text |
| `--ink-2` | `#47443d` | `#c9c5bb` | secondary text |
| `--ink-3` | `#66625a` | `#a29d92` | muted text, meta data |
| `--accent` | `#4338ca` | `#a5b4fc` | links, focus, active navigation, bars |
| `--accent-hover` | `#3730a3` | `#c7d2fe` | hover of links and buttons |
| `--accent-soft` / `--accent-ink` | `#ebeafd` / `#312e81` | `#25264d` / `#c7d2fe` | info notices, accent badges |
| `--on-accent` | `#ffffff` | `#16163a` | text on accent buttons |
| `--mark` / `--mark-ink` | `#fde68a` / `#1c1b18` | `#5a4b10` / `#fff6d5` | highlighter: quotes, search hits |
| `--positive` / `-soft` | `#146c43` / `#e2f2e8` | `#74d3a2` / `#11301f` | "dafür", "angenommen", verified |
| `--negative` / `-soft` | `#b3261e` / `#fbe9e6` | `#ff9d90` / `#3c1612` | "dagegen", "abgelehnt" |
| `--caution` / `-soft` | `#8a5300` / `#fcf0d4` | `#f2c35c` / `#36290c` | legal matters, disputed, dissent, review |
| `--focus` | `#4338ca` | `#a5b4fc` | focus ring |
**Contrast** (WCAG 2.2: text ≥ 4.5, UI parts ≥ 3; computed from the values above):
| Pair | Use | Light | Dark |
|---|---|---|---|
| `--ink` on `--paper` | body text | 15.8 | 15.6 |
| `--ink-2` on `--surface` | secondary text | 9.7 | 10.1 |
| `--ink-3` on `--paper` | muted text on the page | 5.6 | 6.9 |
| `--ink-3` on `--surface-2` | muted text on fills | 5.2 | 5.8 |
| `--accent` on `--surface` | links on cards | 7.9 | 8.7 |
| `--on-accent` on `--accent` | primary button | 7.9 | 8.7 |
| `--accent-ink` on `--accent-soft` | info notice, accent badge | 9.6 | 9.6 |
| `--mark-ink` on `--mark` | highlighted search hit | 13.8 | 7.9 |
| `--positive` on `--positive-soft` | positive badge | 5.6 | 7.9 |
| `--negative` on `--negative-soft` | negative badge | 5.6 | 8.0 |
| `--caution` on `--caution-soft` | caution badge | 5.6 | 8.6 |
| `--line-field` on `--surface` | form field border | 3.9 | 4.0 |
`--line` and `--line-strong` are decorative (card edges, rules) and are never the only way to recognise a
control. Re-run the numbers when you change a colour; the check is twenty lines of Python (relative
luminance per WCAG).
**Party colours** come from the data (`organization.color`) and are used only through
`partyStyle(color)` → `--party`, by `.party-dot`, `.party-band` (a 4 rem bar), `.party-card` (a 4 px top
edge) and party bars in the role timeline. Never as text colour, never as a large background, always next
to the party's name. Dots get a thin ring so dark party colours stay visible on the dark theme.
**Platform colours** (`.platform-bluesky` …) follow the same rule: a dot next to the platform name.
### Typography
System font stack (`--font-sans`): fast, private (no font downloads) and native on every device.
`--font-mono` for identifiers (file numbers, Firmenbuch numbers, item references).
| Token | Size | Use |
|---|---|---|
| `--text-xs` | 0.75 rem | meta data, badges, axis labels |
| `--text-sm` | 0.875 rem | secondary text, tables, filters |
| `--text-base` | 1 rem | body |
| `--text-md` | 1.125 rem | ledes, search field |
| `--text-lg` | 1.25 rem | h2 |
| `--text-xl` | 1.5 rem | key figures, claim title on its permalink page |
| `--text-2xl` | clamp(1.625 → 2.125 rem) | h1 |
| `--text-display` | clamp(2 → 3 rem) | home headline |
Line height 1.55 for text, 1.2 for headings. Headings use `text-wrap: balance`. Running text, list items,
table cells and headings hyphenate (`hyphens: auto` with the document's `lang`), which keeps long German
compounds from breaking layouts on phones.
### Spacing, shape, depth
* Spacing on a 4 px grid: `--space-1` 0.25 rem · `-2` 0.5 · `-3` 0.75 · `-4` 1 · `-5` 1.5 · `-6` 2 ·
`-7` 3 · `-8` 4 rem.
* Radius: `--radius-sm` 4 px (chips, position badges) · `--radius` 8 px (fields, buttons, notices) ·
`--radius-lg` 12 px (cards, panels, tables) · `--radius-pill`.
* Depth: borders first. `--shadow-sm` on hover of link cards; `--shadow` only for overlays (menus,
sticky action bar).
### Layout
* `--page-max` 76 rem content width, `--prose-max` 44 rem for reading, `--gutter` clamp(1–2 rem),
`--header-h` 3.5 rem.
* Breakpoints (media queries can't use custom properties, so they are written out): **640 px** (stacked
tables, collapsed filters, one-column role timeline), **768 px** (calendar → agenda), **1024 px** (full
header navigation, aside columns), **1360 px** (header tool labels).
### Theming
The light theme is defined on `:root`. The dark theme is defined twice with the same values: once in
`@media (prefers-color-scheme: dark)` for `:root:not([data-theme='light'])`, once for
`:root[data-theme='dark']`. `ThemeSwitcher` sets `data-theme` and the `pt_theme` cookie; the layout reads
the cookie, so the server renders the chosen theme without a flash. **A new token goes into all three
blocks.**
## 3. Icons
`<Icon name="…" />` (`src/components/Icon.tsx`): inline SVG on a 24 px grid, 1.75 px strokes in
`currentColor`, decorative (`aria-hidden`) unless you pass `label`. Sizes follow the text (`1em`) unless a
component sets them.
Available: `search person people flag building euro vote calendar message book history sliders external
document check checks alert scale globe sun moon monitor arrow-right arrow-left chevron-down
chevron-right link quote mic plane briefcase landmark info close menu pin news question pencil pie
activity shield code`.
To add one, append a list of path strings (and `c:cx,cy,r` for circles) to `PATHS`. Draw it yourself from
simple geometry rather than copying a set with a licence to track. Claim types map to icons in
`TYPE_ICONS` (`badges.tsx`).
`<BrandMark />` is the logo: brackets around a dot (a citation with its evidence). `src/app/icon.svg` is the
same mark as the favicon.
## 4. Components
| Component | File | Use |
|---|---|---|
| `SiteHeader`, `SiteFooter` | `SiteHeader.tsx`, `SiteFooter.tsx` | page frame; navigation from `nav.ts` |
| `NavLink` (client) | `NavLink.tsx` | navigation link with `aria-current` for the section |
| `ThemeSwitcher`, `LocaleSwitcher` (client) | | system → light → dark; same page in the other language |
| `PageHeader`, `Breadcrumbs`, `SectionHead` | `PageHeader.tsx` | title block; crumbs (home is added); h2 with an "Alle …" link |
| `Tabs` | `Tabs.tsx` | link tabs with counts; scroll shadows when they overflow |
| `FilterBar`, `Field`, `countActive` | `FilterBar.tsx` | GET filter form, collapses on phones |
| `Pagination` | `Pagination.tsx` | previous / page n / next (the API has no totals) |
| `Notice`, `EmptyState` | `Notice.tsx` | notes with tone `note`/`info`/`caution`/`legal`; empty results |
| `ExternalLink` | `ExternalLink.tsx` | link to a source: new tab, no referrer, announced to screen readers |
| `TypeLabel`, `SourceMeter` | `badges.tsx` | claim type with icon; source count / disputed |
| `PartyTag`, `StatusBadge` | `badges.tsx` | party dot + short name; person status |
| `PositionChip`, `ResultBadge` | `badges.tsx` | vote position (optionally per party); accepted/rejected |
| `PlatformBadge`, `platformName` | `badges.tsx` | social platform |
| `Avatar`, `PersonCard` | `Avatar.tsx`, `PersonCard.tsx` | portrait or initials; person in a grid |
| `StatGrid`, `BarList`, `ProportionBar` | `charts.tsx` | key figures; ranked bars; shares of a whole |
| `Timeline`, `DateRangeForm` | `Timeline.tsx` | activity per month (bars link to the month); date filter |
| `RoleTimeline` | `RoleTimeline.tsx` | offices and memberships on a year axis |
| `ClaimList`, `ClaimCard`, `EvidenceList` | `ClaimList.tsx` | the claim card and its sources |
| `ClaimDetails`, `OrgLink` | `ClaimDetails.tsx` | type-specific details (registry `DETAILS`) |
| `MoneyTable` | `MoneyTable.tsx` | money flows in four columns (date + kind, from, to, amount + source chips); `self` mutes the page's own organisation |
| `VoteList`, `VoteLegend` | `VoteList.tsx` | votes (vote items or one person's votes); `compact` for panels |
| `SocialFeed` | `SocialFeed.tsx` | post cards, no embeds |
| `SearchForm` | `SearchForm.tsx` | the big search field (optional date range) |
| `SettingsForm` (client) | `SettingsForm.tsx` | "Meine Filter" |
### The claim card
`ClaimCard` renders, in this order: head (type label, date, subject, source meter), legal stage row,
summary, `ClaimDetails`, innocence notice (`needsInnocenceNotice()`), review notice, topics, and the foot
with the evidence disclosure and the permalink. `standalone` is the permalink page variant: the summary
becomes the `h1` and the sources are expanded. Rules:
* Never render a claim without its evidence.
* Don't remove or move the innocence notice; it belongs inside the card.
* Show the subject only when the list mixes subjects (`showSubject={false}` on profiles).
* When the English UI shows a German summary, it is marked `lang="de"`.
### Filters
```tsx
<FilterBar id="money" active={countActive(sp.kind, sp.q)} resetHref="/geld" hidden={{org: sp.org}}>
<Field label={t('kindLabel')}><select name="kind" defaultValue={sp.kind ?? ''}>…</select></Field>
<Field label={t('nameLabel')} wide><input type="search" name="q" defaultValue={sp.q} /></Field>
</FilterBar>
```
`active` drives the count badge, the reset link and whether the phone toggle starts open. Hidden values
keep context (an organisation, the current tab) across submits.
### Tables
```tsx
<div className="table-wrap">
<table className="table-stack">
<thead><tr><th scope="col">{t('date')}</th>…</tr></thead>
<tbody><tr><td data-label={t('date')}>…</td>…</tr></tbody>
</table>
</div>
```
`.table-wrap` gives the frame and horizontal scrolling. `.table-stack` turns rows into labelled cards
below 640 px, using each cell's `data-label`. Numbers: `className="num"` (right-aligned, tabular).
Amounts: `<span className="amount">`. Keep tables to four or five columns: secondary values (a kind badge, source
chips) go into the cell they belong to rather than into a column of their own.
### Charts
All charts are HTML and CSS, with the numbers as text:
* `StatGrid` is a `<dl>`; values can link to the list behind them.
* `BarList` scales bars to the largest value in the list. Use it for rankings of the same unit only, and
label each row's kind (`meta`).
* `ProportionBar` has `role="img"` with a text alternative, plus a legend with counts.
* `Timeline` bars are links with labels ("März 2026: 12").
* `RoleTimeline` labels every bar with its years. Roles without a start date are listed in the roles panel
only.
## 5. Layout utilities
| Class | Does |
|---|---|
| `.page` | the main column (set by the layout) |
| `.stack`, `.cluster` | vertical stack with gap; wrapping row with gap |
| `.grid-2` | two columns ≥ 22 rem, one column below |
| `.grid-cards` | auto-fill card grid; `--card-min` (default 17 rem) |
| `.layout-aside` | main + 20 rem aside from 1024 px |
| `.section`, `.section-head` | vertical rhythm between sections; h2 row with a "more" link |
| `.prose` | 44 rem reading width |
| `.panel` | aside box with an h2 |
| `.link-list`, `.name-list`, `.meta-list` | ruled list; multi-column names; definition grid |
| `.muted`, `.small`, `.xsmall`, `.strong`, `.num`, `.mono`, `.nowrap`, `.block`, `.visually-hidden` | text utilities |
## 6. Content
### Voice and wording
Sachlich and precise; "du"; gender-inclusive with a colon (Politiker:innen, Funktionär:innen). Say what the
source says, with the source.
| Instead of | Write |
|---|---|
| "X kassierte 25.000 €" | "Laut Rechnungshof erhielt X 25.000 € (Spende)" |
| "Skandal um …", "dubios" | the fact and its source; no adjectives that judge |
| "X ist korrupt", "Anklage!" | the procedural stage ("Ermittlungsverfahren") + the innocence notice |
| "X und Y sind verbandelt" | "X ist laut Firmenbuch Geschäftsführer von Y" + the connection note |
| "X hat 33.000 Follower" (from somewhere) | only data we collected, with its source and date |
### Numbers, dates, money
* Use `formatDate`, `formatMonth`, `formatPeriod`, `formatNumber`, `formatMoney`, `formatMoneyCompact`
from `lib/format.ts`. They use de-AT and en-GB conventions (`12. März 2026`, `€ 25.000`,
`1,2 Mio. €`, `2. Halbjahr 2025`).
* Exact amounts in tables, compact amounts in summaries and bar lists (exact value in the `title`).
* Pass numbers to ICU messages (`{count, plural, …}`), never pre-formatted strings. Pass IDs as strings,
so they don't get thousands separators.
* Never add up different kinds of money into one figure without saying so.
### Interface text
* Every key goes into both `messages/de.json` and `messages/en.json`; German first.
* Labels are short nouns ("Quellen", "Filter"); buttons are verbs ("Anwenden", "Speichern").
* Links say where they go ("Alle Abstimmungen", "Original ansehen"), never "hier klicken".
* Keep a dash with the preceding word using a non-breaking space where a line break would start a line
with "–" (the home headline does this).
## 7. Accessibility checklist (for every UI change)
- [ ] Headings in order, one `h1`; landmarks intact; new sections have `aria-labelledby`.
- [ ] Everything works with the keyboard and shows the focus ring.
- [ ] Nothing relies on colour alone: badges have words, positions have icons, charts have numbers.
- [ ] Contrast of new colours checked (§2).
- [ ] External links use `ExternalLink` (new tab announced).
- [ ] Images: `alt=""` when decorative (the name is next to the portrait), real text otherwise.
- [ ] Phone width (390 px): no horizontal scrolling, tables stack, nothing cut off.
- [ ] Both themes look right; both languages fit (English is shorter, German compounds are longer).
## 8. Recipes
**An index page.** Use `PageHeader` → `FilterBar` → results (`grid-cards`, a `table-wrap`, or
`ClaimList`) → `Pagination` → a source `Notice`. The persons and organisations pages are the templates.
**A profile section.** Use a `section` with `aria-labelledby` and a `SectionHead` (with `more` pointing to
the pre-filtered explorer), then the list component. Put secondary facts into a `.panel` in the aside.
**Showing a new claim type.** Add a renderer to `DETAILS` in `ClaimDetails.tsx`, an icon to `TYPE_ICONS`
in `badges.tsx`, and the label `Claims.type.<type>` in both message files.
**A new chart.** Prefer `StatGrid`, `BarList` or `ProportionBar`. If you need something else, build it from
HTML/CSS with the numbers as text, add its section to `components.css`, and document it here.
**A new token.** Add it to all three blocks in `tokens.css`, check its contrast, and add it to §2.
+10
View File
@@ -34,6 +34,12 @@ Next.js frontend (de/en), Docker Compose for Unraid.
- [x] Pages: organisations, money explorer, social feed, Wikipedia edits, person network ("follow the money"),
say-vs-vote on roll calls
**Design (v2)** – see [design/README.md](design/README.md):
- [x] Design concept and design system (neutral palette, light/dark, tokens with checked contrast, icons)
- [x] Redesigned pages: header with search and filter count, profiles with role timeline and key figures,
party and organisation dashboards, vote list with position chips, calendar agenda on phones
- [x] Claim permalink pages with citation (`/angaben/{id}`, `GET /api/v1/claims/{id}`)
## Next
**Data**
@@ -53,6 +59,10 @@ Next.js frontend (de/en), Docker Compose for Unraid.
- [ ] Wayback Machine archiving of evidence URLs (`document.archive_url`)
**Product**
- [ ] Before going public: Impressum/Offenlegung (§ 5 ECG, § 25 MedienG), privacy policy, a corrections contact
- [ ] Image proxy for portraits and logos (no visitor IPs to Wikimedia/parliament)
- [ ] Compare two people or parties; topic pages (`/themen/{slug}`); Open Graph share images; CSV export
- [ ] Review queue for editors in the web UI (today `politrack review`)
- [ ] Community link submissions and a corrections/right-of-reply form
- [ ] Alerts (e-mail/RSS) for followed politicians
- [ ] Optional accounts via reverse-proxy SSO using `app_user`
+244 -37
View File
@@ -4,7 +4,6 @@
"description": "Was Österreichs Politikerinnen und Politiker tun: Abstimmungen, Geld, Auftritte, Aussagen – jede Angabe mit Quelle."
},
"Nav": {
"home": "Start",
"persons": "Personen",
"parties": "Parteien",
"votes": "Abstimmungen",
@@ -16,22 +15,51 @@
"former": "Ehemalige",
"organizations": "Organisationen",
"money": "Geld",
"social": "Social Media"
"social": "Social Media",
"wikipedia": "Wikipedia-Bearbeitungen"
},
"Home": {
"headline": "Politik in Österreich – nachvollziehbar.",
"headline": "Politik in Österreich – nachvollziehbar.",
"intro": "Politrack sammelt öffentlich zugängliche Informationen über Politikerinnen, Politiker und Parteien und verlinkt jede Angabe mit ihren Quellen. Du entscheidest, welchen Quellen du vertraust.",
"stats": "{persons} Personen ({active} aktiv) · {claims} belegte Angaben, davon {corroborated} mehrfach bestätigt · {votes} Abstimmungen ({rollCalls} namentlich) · {money} Geldflüsse",
"lastUpdate": "Zuletzt aktualisiert: {date}",
"latest": "Neueste Angaben",
"filtersActive": "Deine Filter sind aktiv: {sources} Quellen ausgeblendet, {topics} Themen gewählt.",
"filtersActive": "Deine Filter sind aktiv ({count}) – manche Angaben sind ausgeblendet.",
"explore": "Entdecken",
"exploreMoney": "Geld: Spenden, Aufträge, Inserate, Parteienförderung",
"exploreOrganizations": "Unternehmen, Vereine, Behörden – und ihre Verbindungen",
"exploreSocial": "Was Politiker:innen öffentlich posten",
"exploreWiki": "Wikipedia-Bearbeitungen aus Parlament und Regierung",
"exploreFormer": "Was machen Ehemalige heute?",
"exploreCalendar": "Kalender: Sitzungen, Reisen, Treffen"
"exploreCalendar": "Sitzungen, Ausschüsse, Ministerrat, Reisen und Treffen",
"quick": "Schnellzugriff",
"quickDonations": "Parteispenden",
"quickRollCalls": "Namentliche Abstimmungen",
"quickAds": "Regierungsinserate",
"quickCalendar": "Kalender",
"statPersons": "Personen, davon {active} aktiv",
"statClaims": "belegte Angaben, {corroborated} mehrfach belegt",
"statVotes": "Abstimmungen, {rollCalls} namentlich",
"statMoney": "Geldflüsse aus amtlichen Registern",
"changeFilters": "Filter ändern",
"searchInTime": "Suche in der Zeit",
"parties": "Parteien",
"activeOfficials": "{count} aktiv",
"latestVotes": "Letzte Abstimmungen",
"allVotes": "Alle Abstimmungen",
"exploreMoneyTitle": "Geld",
"exploreOrganizationsTitle": "Organisationen",
"exploreSocialTitle": "Social Media",
"exploreCalendarTitle": "Kalender",
"exploreFormerTitle": "Ehemalige",
"exploreWikiTitle": "Wikipedia-Bearbeitungen",
"howTitle": "So arbeitet Politrack",
"howMore": "Methodik & Quellen",
"howEvidenceTitle": "Jede Angabe mit Beleg",
"howEvidence": "Angaben stammen aus amtlichen Daten, offenen Registern und Medienberichten. Jede verlinkt ihre Quellen – du kannst alles nachprüfen.",
"howCorroborationTitle": "Unabhängig bestätigt",
"howCorroboration": "Berichten mehrere unabhängige Quellen dasselbe, gilt eine Angabe als mehrfach belegt. Widerspricht eine Quelle, wird sie als umstritten markiert.",
"howInnocenceTitle": "Fair und neutral",
"howInnocence": "Bei Verfahren zeigen wir immer den Stand. Bis zu einer rechtskräftigen Verurteilung gilt die Unschuldsvermutung."
},
"Search": {
"placeholder": "Person, Thema oder Stichwort …",
@@ -39,7 +67,13 @@
"results": "Ergebnisse für „{q}“",
"none": "Keine Treffer.",
"from": "Von",
"to": "Bis"
"to": "Bis",
"title": "Suche",
"intro": "Suche nach Personen, Themen und Stichworten in allen Angaben und Dokumenten – oder gib nur einen Zeitraum an, um alles zu sehen, was in dieser Zeit passiert ist.",
"label": "Suchbegriff",
"inTime": "Angaben im Zeitraum {range}",
"tips": "Tipp: Gib nur ein Datum oder einen Zeitraum ein (ohne Suchbegriff), um alle Angaben aus dieser Zeit zu sehen.",
"details": "Angabe ansehen"
},
"Persons": {
"title": "Personen",
@@ -64,14 +98,17 @@
"deceased": "Verstorben",
"all": "Alle"
},
"lastOffice": "Letztes Amt bis {date}"
"lastOffice": "Letztes Amt bis {date}",
"intro": "Abgeordnete, Regierungsmitglieder, Landes- und Gemeindepolitiker:innen sowie Funktionär:innen der Parteien.",
"name": "Name",
"namePlaceholder": "z. B. Nachname",
"unverifiedStatus": "Aktuelles Amt nur laut Wikidata – nicht amtlich bestätigt",
"roleMp": "Abgeordnete:r"
},
"Person": {
"roles": "Funktionen & Mandate",
"current": "aktuell",
"since": "seit {date}",
"period": "{from} – {to}",
"social": "Kanäle",
"tabs": {
"overview": "Überblick",
"votes": "Abstimmungen",
@@ -84,21 +121,38 @@
"social": "Social Media"
},
"factionVoteNote": "Im Nationalrat wird meist per Aufstehen abgestimmt; offiziell protokolliert ist dann nur das Stimmverhalten der Fraktion. Bei namentlichen Abstimmungen zeigen wir die eigene Stimme – und ob sie von der Fraktion abweicht.",
"noVotes": "Keine Abstimmungen erfasst.",
"faction": "Fraktion {faction}",
"statusNote": "Status wird aus den Mandaten und Funktionen berechnet: „aktiv“ nur mit bestätigtem aktuellem Amt.",
"roleCorroborated": "mehrfach belegt",
"unverified": "laut Wikidata – nicht amtlich bestätigt",
"accountUnverified": "Laut Wikidata – nicht amtlich bestätigt"
"accountUnverified": "Laut Wikidata – nicht amtlich bestätigt",
"metaDescription": "{name}: Funktionen, Abstimmungen, Geldflüsse, Aussagen und Medienberichte – jede Angabe mit Quelle.",
"member": "Mitglied",
"born": "geboren {date}",
"died": "gestorben {date}",
"elsewhere": "Profile und Kanäle",
"parliamentProfile": "Parlament",
"epProfile": "Europäisches Parlament",
"sections": "Bereiche",
"career": "Laufbahn",
"latest": "Neueste Angaben",
"allClaims": "Alle Angaben",
"glanceTitle": "Auf einen Blick",
"glance": {
"votes": "Abstimmungen",
"statements": "Reden & Aussagen",
"questions": "Anfragen",
"appearances": "Termine & Reisen",
"sideJobs": "Nebentätigkeiten",
"media": "Medienberichte"
},
"moneyClaims": "Angaben zu Geld",
"legalNote": "Verfahren werden mit ihrem Stand gezeigt; automatisch erkannte erscheinen erst nach redaktioneller Prüfung. Es gilt die Unschuldsvermutung.",
"datePrecision": "Bei Reisen und Treffen ist das Datum oft das Veröffentlichungsdatum der Quelle."
},
"Parties": {
"title": "Parteien",
"activeOfficials": "{count} aktive Funktionsträger:innen",
"activeOfficials": "{count, plural, one {# aktive:r Funktionsträger:in} other {# aktive Funktionsträger:innen}}",
"members": "Aktuelle Funktionsträger:innen",
"donations": "Gemeldete Spenden (Rechnungshof)",
"year": "Jahr",
"total": "Summe",
"count": "Anzahl",
"voteStats": "Stimmverhalten",
"recentVotes": "Letzte Abstimmungen",
"claims": "Belegte Angaben",
@@ -115,7 +169,20 @@
"ownership": "Beteiligung"
},
"channels": "Kanäle der Partei und ihrer Mitglieder",
"allPosts": "Alle Beiträge"
"allPosts": "Alle Beiträge",
"intro": "Parteien mit ihren aktuellen Funktionsträger:innen, Abstimmungen, Finanzen und nahestehenden Organisationen.",
"tracked": "{count, plural, one {# Person erfasst} other {# Personen erfasst}}",
"statOfficials": "aktuelle Funktionsträger:innen",
"statFormer": "Ehemalige",
"statVotes": "erfasste Fraktionsstimmen",
"statDissent": "Mandatar:innen mit abweichenden Stimmen",
"voteStatsNote": "Stimmverhalten der Fraktion in allen erfassten Abstimmungen.",
"noMoney": "Keine Geldflüsse erfasst.",
"allMembers": "Alle {count} anzeigen",
"relatedNote": "Laut Rechenschaftsbericht der Partei.",
"allFormer": "Alle Ehemaligen",
"lastMention": "zuletzt in den Medien {date}",
"dissentCount": "{count, plural, one {# abweichende Stimme} other {# abweichende Stimmen}}"
},
"Votes": {
"title": "Abstimmungen",
@@ -129,13 +196,22 @@
"item": "Gegenstand",
"rollCall": "namentliche Abstimmung",
"dissent": "gegen die eigene Fraktion",
"factionVote": "Fraktion",
"ownVote": "eigene Stimme",
"individualVotes": "Einzelstimmen",
"onlyRollCall": "Nur namentliche Abstimmungen",
"splitVote": "Getrennte Abstimmung – Details im Protokoll",
"spoke": "Rede dazu",
"speech": "Rede"
"speech": "Rede",
"intro": "Wie die Fraktionen im Nationalrat und Bundesrat abgestimmt haben – und bei namentlichen Abstimmungen jede einzelne Stimme.",
"none": "Keine Abstimmungen gefunden.",
"legend": "Legende",
"factionVoteOf": "Stimme der Fraktion {faction}",
"vote": "Abstimmung",
"factions": "Fraktionen",
"factionOnly": "Protokolliert ist das Stimmverhalten der Fraktionen, nicht einzelner Abgeordneter.",
"dissentSummary": "{count, plural, one {# Abgeordnete:r stimmte} other {# Abgeordnete stimmten}} gegen die eigene Fraktion.",
"protocolText": "Wortlaut im Protokoll",
"filteredParty": "Gefiltert nach Partei: {party}"
},
"Claims": {
"sources": "{count, plural, one {# Quelle} other {# Quellen}}",
@@ -149,7 +225,6 @@
"mentions": "erwähnt",
"contradicts": "widerspricht",
"none": "Keine Angaben mit deinen Quellen- und Themenfiltern.",
"more": "Weitere laden",
"type": {
"role": "Funktion",
"vote": "Abstimmung",
@@ -188,7 +263,11 @@
"proceedings_dropped": "Verfahren eingestellt",
"diversion": "Diversion (keine Verurteilung)"
},
"pending": "Noch nicht redaktionell geprüft"
"pending": "Noch nicht redaktionell geprüft",
"evidence": "{count, plural, one {# Beleg} other {# Belege}}",
"permalink": "Permalink",
"permalinkHint": "Eigene Seite dieser Angabe – zum Teilen und Zitieren",
"archived": "archivierte Kopie"
},
"Settings": {
"title": "Meine Filter",
@@ -208,7 +287,9 @@
"aggregator": "Datenbanken",
"user": "Community",
"derived": "Abgeleitet"
}
},
"sourcesHint": "Ausgeschaltete Quellen werden überall ausgeblendet. Die Zahl ist der voreingestellte Vertrauenswert (0–100).",
"trust": "Vertrauenswert"
},
"Method": {
"title": "Methodik & Quellen",
@@ -217,11 +298,65 @@
"p3": "Zu strafrechtlichen Angelegenheiten zeigen wir stets den Verfahrensstand. Bis zu einer rechtskräftigen Verurteilung gilt die Unschuldsvermutung.",
"license": "Lizenz",
"access": "Zugriff",
"trust": "Vertrauen"
"trust": "Vertrauen",
"intro": "Wie Politrack Angaben sammelt, prüft und zeigt.",
"toc": "Inhalt",
"section": {
"claims": "Angaben und Belege",
"certainty": "Wie sicher eine Angabe ist",
"legal": "Rechtliche Verfahren",
"sources": "Quellen",
"notCollected": "Was wir nicht erfassen",
"data": "Daten für Entwickler:innen und KI-Agenten",
"corrections": "Korrekturen"
},
"certaintyIntro": "Diese Kennzeichen bedeuten überall auf Politrack dasselbe:",
"reported": "Eine Quelle berichtet die Angabe.",
"corroborated": "Mehrere unabhängige Quellen bestätigen die Angabe.",
"disputed": "Mindestens eine Quelle widerspricht – beide Seiten sind verlinkt.",
"activeVerified": "Aktuelles Amt laut Parlament oder Europäischem Parlament bestätigt.",
"activeUnverified": "Aktuelles Amt nur laut Wikidata, nicht amtlich bestätigt.",
"accountUnverified": "Social-Media-Konto nur laut Wikidata, von keiner offiziellen Seite bestätigt.",
"stage": {
"allegation_reported": "Ein Medium oder eine andere Quelle berichtet über einen Vorwurf; ein Verfahren ist nicht bekannt.",
"investigation": "Staatsanwaltschaft oder Polizei ermitteln. Das sagt nichts über Schuld aus – viele Ermittlungen werden eingestellt.",
"immunity_waiver_requested": "Eine Behörde ersucht das Parlament, die Immunität aufzuheben, um ermitteln zu können.",
"immunity_waived": "Das Parlament hat die Immunität aufgehoben; Ermittlungen sind damit möglich, eine Anklage gibt es dadurch nicht.",
"immunity_refused": "Das Parlament hat die Aufhebung der Immunität abgelehnt.",
"indicted": "Die Staatsanwaltschaft hat Anklage erhoben; ein Gericht entscheidet.",
"trial": "Das Gerichtsverfahren läuft.",
"convicted_not_final": "Ein Gericht hat verurteilt, das Urteil ist aber nicht rechtskräftig (Rechtsmittel möglich).",
"convicted_final": "Rechtskräftige Verurteilung – das Verfahren ist abgeschlossen.",
"acquitted": "Das Gericht hat freigesprochen.",
"proceedings_dropped": "Das Verfahren wurde eingestellt.",
"diversion": "Erledigung ohne Verurteilung (z. B. Geldbuße oder gemeinnützige Leistung); kein Schuldspruch, kein Strafregistereintrag."
},
"sourcesIntro": "Alle Quellen mit Lizenz, Zugriffsart und voreingestelltem Vertrauenswert. Unter „Meine Filter“ kannst du einzelne Quellen ausblenden oder einen Mindestwert festlegen.",
"source": "Quelle",
"kind": "Art",
"notCollected": "Wir umgehen keine Bezahlschranken, Logins oder technischen Sperren und halten uns an robots.txt und Nutzungsbedingungen. Von Medien speichern wir nur Überschrift, Teaser und Link.",
"data": "Die Betreiber:innen dieser Instanz stellen die Daten auch maschinenlesbar bereit: als REST-API mit OpenAPI-Dokumentation, als MCP-Server für KI-Agenten und als Lesezugriff auf die Datenbank. Jede Angabe kommt dort mit denselben Belegen wie hier.",
"corrections": "Fehler gefunden? Wir korrigieren falsche Angaben, markieren widersprochene als umstritten und löschen nichts stillschweigend. Bitte nenne die Nummer der Angabe (siehe Permalink) und eine Quelle für die Korrektur."
},
"Common": {
"loadError": "Daten konnten nicht geladen werden.",
"notFound": "Nicht gefunden."
"loadError": "Die Daten konnten nicht geladen werden. Bitte versuche es gleich noch einmal.",
"notFound": "Diese Seite gibt es nicht (mehr). Vielleicht hilft die Suche weiter.",
"home": "Start",
"persons": "Personen",
"search": "Suche",
"breadcrumb": "Navigationspfad",
"filters": "Filter",
"apply": "Anwenden",
"reset": "Zurücksetzen",
"previous": "Zurück",
"next": "Weiter",
"page": "Seite {page}",
"pagination": "Seiten",
"newTab": "öffnet in neuem Tab",
"loading": "Wird geladen …",
"retry": "Erneut versuchen",
"errorTitle": "Das hat nicht geklappt",
"notFoundTitle": "Seite nicht gefunden"
},
"Timeline": {
"from": "Von",
@@ -233,9 +368,9 @@
"Calendar": {
"title": "Kalender",
"intro": "Sitzungen, Ausschüsse, Reisen, Treffen und Auftritte – mit Quellen.",
"prev": "← Vormonat",
"next": "Nächster Monat →",
"person": "Person (Kurzname)",
"prev": "Vormonat",
"next": "Nächster Monat",
"person": "Person",
"none": "Keine Termine in diesem Zeitraum.",
"activity": "{count} Angaben",
"kind": {
@@ -252,21 +387,42 @@
"hearing": "Befragung (U-Ausschuss)",
"government_meeting": "Ministerrat"
},
"datePrecision": "Datum = Veröffentlichungsdatum der Quelle"
"datePrecision": "Datum = Veröffentlichungsdatum der Quelle",
"today": "Heute",
"personPlaceholder": "Name",
"kindLabel": "Art",
"allKinds": "Alle Arten",
"legend": "Legende",
"group": {
"parliament": "Parlament",
"government": "Regierung",
"travel": "Reisen & Treffen",
"public": "Öffentliche Auftritte"
},
"more": "+{count} weitere",
"activityHint": "Alle Angaben von diesem Tag"
},
"Former": {
"title": "Was machen ehemalige Politikerinnen und Politiker?",
"intro": "Ehemalige Mandatarinnen, Mandatare und Funktionsträger:innen – sortiert nach der jüngsten Berichterstattung.",
"lastRole": "Zuletzt: {role}",
"latestNews": "Zuletzt in den Medien",
"career": "Werdegang"
"career": "Werdegang",
"none": "Keine ehemaligen Politiker:innen gefunden."
},
"ClaimDetails": {
"with": "mit",
"reference": "Ref.",
"article": "Artikel",
"fileNumber": "GZ",
"financeDetails": "Details Rechenschaftsbericht {year}"
"financeDetails": "Details Rechenschaftsbericht {year}",
"metric": {
"likes": "Likes",
"reposts": "Reposts",
"favourites": "Favoriten",
"reblogs": "Boosts",
"views": "Aufrufe"
}
},
"Money": {
"title": "Geld",
@@ -301,7 +457,10 @@
"side_income": "Nebeneinkommen",
"sponsorship": "Sponsoring",
"other": "Sonstiges"
}
},
"flows": "Einzelne Geldflüsse",
"nameLabel": "Name",
"asReported": "Beträge wie gemeldet."
},
"Organizations": {
"title": "Organisationen",
@@ -350,7 +509,14 @@
"authority": "Behörde",
"eu_institution": "EU-Institution",
"other": "Sonstige"
}
},
"receivedTotal": "erhalten (gesamt)",
"paidTotal": "gezahlt (gesamt)",
"counterpartyCount": "Gegenparteien",
"receivedFromTitle": "Erhielt Geld von",
"paidToTitle": "Zahlte an",
"noMoney": "Keine Geldflüsse erfasst.",
"identifiers": "Kennungen"
},
"Network": {
"title": "Netzwerk: verbundene Organisationen",
@@ -366,7 +532,10 @@
"role": "Funktion",
"lobbying": "Lobbying",
"party": "Partei"
}
},
"organization": "Organisation",
"connection": "Verbindung",
"money": "Geld dieser Organisation"
},
"Social": {
"title": "Social Media",
@@ -389,6 +558,44 @@
"network": "Netzwerk",
"article": "Artikel",
"change": "Änderung",
"note": "Eine Bearbeitung aus einem Netzwerk sagt nichts darüber, welche Person sie vorgenommen hat. IP-Bereiche: config/ip_ranges.yaml."
"note": "Eine Bearbeitung aus einem Netzwerk sagt nichts darüber, welche Person sie vorgenommen hat.",
"ranges": "Die IP-Bereiche stehen in config/ip_ranges.yaml im Quellcode."
},
"Layout": {
"skip": "Zum Inhalt springen",
"homeLabel": "Politrack – Startseite",
"mainNav": "Hauptnavigation",
"footerNav": "Seitenübersicht",
"more": "Mehr",
"menu": "Menü",
"searchLabel": "Suche",
"searchPlaceholder": "Suchen …",
"filtersActive": "Meine Filter: {count} aktiv",
"themeLabel": "Farbschema: {current} – wechseln zu {next}",
"theme": {
"system": "wie System",
"light": "hell",
"dark": "dunkel"
},
"group": {
"people": "Menschen",
"decisions": "Entscheidungen",
"influence": "Geld & Einfluss",
"about": "Über Politrack"
}
},
"Footer": {
"tagline": "Österreichs Politik, belegt. Ein unabhängiges Projekt, das öffentlich zugängliche Informationen über Politikerinnen, Politiker und Parteien sammelt – jede Angabe mit Quelle.",
"principleEvidence": "Jede Angabe mit Beleg",
"principleInnocence": "Es gilt die Unschuldsvermutung",
"principlePrivacy": "Keine Werbung, kein Tracking",
"developers": "Daten, API & KI-Agenten",
"bottom": "Angaben ohne Gewähr. Jede Angabe verweist auf ihre Quellen – maßgeblich sind diese."
},
"ClaimPage": {
"crumb": "Angabe #{id}",
"cite": "Zitieren",
"citation": "Politrack: „{summary}“. Angabe #{id}, abgerufen am {date}. {url}",
"filtersIgnored": "Diese Seite zeigt die Angabe mit allen Quellen – unabhängig von deinen Filtern, damit ein geteilter Link für alle gleich aussieht."
}
}
+244 -37
View File
@@ -4,7 +4,6 @@
"description": "What Austria's politicians do: votes, money, appearances, statements – every claim with its sources."
},
"Nav": {
"home": "Home",
"persons": "People",
"parties": "Parties",
"votes": "Votes",
@@ -16,22 +15,51 @@
"former": "Former politicians",
"organizations": "Organisations",
"money": "Money",
"social": "Social media"
"social": "Social media",
"wikipedia": "Wikipedia edits"
},
"Home": {
"headline": "Austrian politics – traceable.",
"headline": "Austrian politics – traceable.",
"intro": "Politrack collects publicly available information about politicians and parties and links every claim to its sources. You decide which sources you trust.",
"stats": "{persons} people ({active} active) · {claims} sourced claims, {corroborated} corroborated · {votes} votes ({rollCalls} roll calls) · {money} money flows",
"lastUpdate": "Last updated: {date}",
"latest": "Latest claims",
"filtersActive": "Your filters are active: {sources} sources hidden, {topics} topics selected.",
"filtersActive": "Your filters are active ({count}) – some facts are hidden.",
"explore": "Explore",
"exploreMoney": "Money: donations, contracts, ads, party funding",
"exploreOrganizations": "Companies, associations, authorities – and their connections",
"exploreSocial": "What politicians post publicly",
"exploreWiki": "Wikipedia edits from parliament and government",
"exploreFormer": "What are former politicians doing?",
"exploreCalendar": "Calendar: sessions, trips, meetings"
"exploreCalendar": "Sessions, committees, Council of Ministers, trips and meetings",
"quick": "Quick links",
"quickDonations": "Party donations",
"quickRollCalls": "Roll-call votes",
"quickAds": "Government ads",
"quickCalendar": "Calendar",
"statPersons": "people, {active} in office",
"statClaims": "sourced facts, {corroborated} corroborated",
"statVotes": "votes, {rollCalls} roll calls",
"statMoney": "money flows from official registers",
"changeFilters": "Change filters",
"searchInTime": "Search in time",
"parties": "Parties",
"activeOfficials": "{count} active",
"latestVotes": "Latest votes",
"allVotes": "All votes",
"exploreMoneyTitle": "Money",
"exploreOrganizationsTitle": "Organisations",
"exploreSocialTitle": "Social media",
"exploreCalendarTitle": "Calendar",
"exploreFormerTitle": "Former politicians",
"exploreWikiTitle": "Wikipedia edits",
"howTitle": "How Politrack works",
"howMore": "Methodology & sources",
"howEvidenceTitle": "Every fact has a source",
"howEvidence": "Facts come from official data, open registers and media reports. Each links to its sources, so you can check everything.",
"howCorroborationTitle": "Independently confirmed",
"howCorroboration": "When several independent sources report the same thing, a fact counts as corroborated. If a source contradicts it, it is marked as disputed.",
"howInnocenceTitle": "Fair and neutral",
"howInnocence": "For legal proceedings we always show the stage. Until a final conviction, the presumption of innocence applies."
},
"Search": {
"placeholder": "Person, topic or keyword …",
@@ -39,7 +67,13 @@
"results": "Results for “{q}”",
"none": "No results.",
"from": "From",
"to": "To"
"to": "To",
"title": "Search",
"intro": "Search people, topics and keywords across all facts and documents – or enter only a date range to see everything that happened in that period.",
"label": "Search term",
"inTime": "Facts from {range}",
"tips": "Tip: enter only a date or a date range (no search term) to see all facts from that time.",
"details": "View fact"
},
"Persons": {
"title": "People",
@@ -64,14 +98,17 @@
"deceased": "Deceased",
"all": "All"
},
"lastOffice": "Last office until {date}"
"lastOffice": "Last office until {date}",
"intro": "MPs, members of government, state and municipal politicians, and party officials.",
"name": "Name",
"namePlaceholder": "e.g. last name",
"unverifiedStatus": "Current office only according to Wikidata – not officially confirmed",
"roleMp": "Member of parliament"
},
"Person": {
"roles": "Offices & mandates",
"current": "current",
"since": "since {date}",
"period": "{from} – {to}",
"social": "Channels",
"tabs": {
"overview": "Overview",
"votes": "Votes",
@@ -84,21 +121,38 @@
"social": "Social media"
},
"factionVoteNote": "The National Council mostly votes by standing up; then only each faction's vote is recorded. For roll-call votes we show the person's own vote – and whether it differs from their faction.",
"noVotes": "No votes recorded.",
"faction": "Faction {faction}",
"statusNote": "Status is derived from mandates and functions: \"active\" only with a verified current office.",
"roleCorroborated": "corroborated",
"unverified": "per Wikidata – not officially confirmed",
"accountUnverified": "According to Wikidata – not officially confirmed"
"accountUnverified": "According to Wikidata – not officially confirmed",
"metaDescription": "{name}: offices, votes, money, statements and media reports – every fact with its source.",
"member": "Member",
"born": "born {date}",
"died": "died {date}",
"elsewhere": "Profiles and channels",
"parliamentProfile": "Parliament",
"epProfile": "European Parliament",
"sections": "Sections",
"career": "Career",
"latest": "Latest facts",
"allClaims": "All facts",
"glanceTitle": "At a glance",
"glance": {
"votes": "votes",
"statements": "speeches & statements",
"questions": "questions",
"appearances": "appointments & trips",
"sideJobs": "side jobs",
"media": "media reports"
},
"moneyClaims": "Money-related facts",
"legalNote": "Proceedings are shown with their stage; automatically detected ones appear only after editorial review. The presumption of innocence applies.",
"datePrecision": "For trips and meetings the date is often the publication date of the source."
},
"Parties": {
"title": "Parties",
"activeOfficials": "{count} active officials",
"activeOfficials": "{count, plural, one {# active official} other {# active officials}}",
"members": "Current officials",
"donations": "Reported donations (Court of Audit)",
"year": "Year",
"total": "Total",
"count": "Count",
"voteStats": "Voting record",
"recentVotes": "Recent votes",
"claims": "Sourced claims",
@@ -115,7 +169,20 @@
"ownership": "holding"
},
"channels": "Channels of the party and its members",
"allPosts": "All posts"
"allPosts": "All posts",
"intro": "Parties with their current officials, votes, finances and affiliated organisations.",
"tracked": "{count, plural, one {# person tracked} other {# people tracked}}",
"statOfficials": "current officials",
"statFormer": "former members",
"statVotes": "faction votes recorded",
"statDissent": "members who voted against their faction",
"voteStatsNote": "The faction's positions in all recorded votes.",
"noMoney": "No money flows recorded.",
"allMembers": "Show all {count}",
"relatedNote": "According to the party's annual report.",
"allFormer": "All former members",
"lastMention": "last in the news {date}",
"dissentCount": "{count, plural, one {# dissenting vote} other {# dissenting votes}}"
},
"Votes": {
"title": "Votes",
@@ -129,13 +196,22 @@
"item": "Item",
"rollCall": "roll-call vote",
"dissent": "against own faction",
"factionVote": "faction",
"ownVote": "own vote",
"individualVotes": "Individual votes",
"onlyRollCall": "Roll-call votes only",
"splitVote": "Split vote – see transcript",
"spoke": "Spoke on this",
"speech": "Speech"
"speech": "Speech",
"intro": "How the factions voted in the Nationalrat and Bundesrat – and, in roll-call votes, every individual vote.",
"none": "No votes found.",
"legend": "Legend",
"factionVoteOf": "vote of the {faction} faction",
"vote": "Vote",
"factions": "Factions",
"factionOnly": "The record shows how each faction voted, not individual MPs.",
"dissentSummary": "{count, plural, one {# member voted} other {# members voted}} against their faction.",
"protocolText": "Wording in the record",
"filteredParty": "Filtered by party: {party}"
},
"Claims": {
"sources": "{count, plural, one {# source} other {# sources}}",
@@ -149,7 +225,6 @@
"mentions": "mentions",
"contradicts": "contradicts",
"none": "No claims match your source and topic filters.",
"more": "Load more",
"type": {
"role": "Office",
"vote": "Vote",
@@ -188,7 +263,11 @@
"proceedings_dropped": "Proceedings dropped",
"diversion": "Diversion (no conviction)"
},
"pending": "Not yet reviewed"
"pending": "Not yet reviewed",
"evidence": "{count, plural, one {# source} other {# sources}}",
"permalink": "Permalink",
"permalinkHint": "This fact's own page – for sharing and citing",
"archived": "archived copy"
},
"Settings": {
"title": "My filters",
@@ -208,7 +287,9 @@
"aggregator": "Databases",
"user": "Community",
"derived": "Derived"
}
},
"sourcesHint": "Sources you switch off are hidden everywhere. The number is the default trust score (0–100).",
"trust": "Trust score"
},
"Method": {
"title": "Method & sources",
@@ -217,11 +298,65 @@
"p3": "For criminal matters we always show the procedural stage. The presumption of innocence applies until a final conviction.",
"license": "Licence",
"access": "Access",
"trust": "Trust"
"trust": "Trust",
"intro": "How Politrack collects, checks and presents facts.",
"toc": "Contents",
"section": {
"claims": "Facts and sources",
"certainty": "How certain a fact is",
"legal": "Legal proceedings",
"sources": "Sources",
"notCollected": "What we don't collect",
"data": "Data for developers and AI agents",
"corrections": "Corrections"
},
"certaintyIntro": "These labels mean the same everywhere on Politrack:",
"reported": "One source reports the fact.",
"corroborated": "Several independent sources confirm the fact.",
"disputed": "At least one source contradicts it – both sides are linked.",
"activeVerified": "Current office confirmed by the Austrian or European Parliament.",
"activeUnverified": "Current office only according to Wikidata, not officially confirmed.",
"accountUnverified": "Social media account only according to Wikidata, not confirmed by an official source.",
"stage": {
"allegation_reported": "A media outlet or another source reports an allegation; no proceedings are known.",
"investigation": "Prosecutors or police are investigating. This says nothing about guilt – many investigations are dropped.",
"immunity_waiver_requested": "An authority asks parliament to lift immunity so it can investigate.",
"immunity_waived": "Parliament has lifted immunity; this allows an investigation and is not an indictment.",
"immunity_refused": "Parliament has refused to lift immunity.",
"indicted": "Prosecutors have brought charges; a court will decide.",
"trial": "The trial is ongoing.",
"convicted_not_final": "A court has convicted, but the judgment is not final (an appeal is possible).",
"convicted_final": "Final conviction – the proceedings are closed.",
"acquitted": "The court has acquitted.",
"proceedings_dropped": "The proceedings were dropped.",
"diversion": "Settled without a conviction (e.g. a fine or community service); no finding of guilt, no criminal record."
},
"sourcesIntro": "All sources with their licence, access method and default trust score. Under “My filters” you can hide individual sources or set a minimum score.",
"source": "Source",
"kind": "Kind",
"notCollected": "We don't get around paywalls, logins or technical barriers, and we respect robots.txt and terms of use. From media we store only the headline, teaser and link.",
"data": "The operators of this instance also provide the data in machine-readable form: a REST API with OpenAPI documentation, an MCP server for AI agents and read access to the database. Every fact comes with the same sources as here.",
"corrections": "Found a mistake? We correct wrong facts, mark contradicted ones as disputed and never delete anything silently. Please quote the fact's number (see its permalink) and a source for the correction."
},
"Common": {
"loadError": "Could not load data.",
"notFound": "Not found."
"loadError": "The data could not be loaded. Please try again in a moment.",
"notFound": "This page doesn't exist (any more). The search may help.",
"home": "Home",
"persons": "People",
"search": "Search",
"breadcrumb": "Breadcrumb",
"filters": "Filters",
"apply": "Apply",
"reset": "Reset",
"previous": "Previous",
"next": "Next",
"page": "Page {page}",
"pagination": "Pages",
"newTab": "opens in a new tab",
"loading": "Loading …",
"retry": "Try again",
"errorTitle": "Something went wrong",
"notFoundTitle": "Page not found"
},
"Timeline": {
"from": "From",
@@ -233,9 +368,9 @@
"Calendar": {
"title": "Calendar",
"intro": "Sessions, committees, trips, meetings and appearances – with sources.",
"prev": "← Previous month",
"next": "Next month →",
"person": "Person (slug)",
"prev": "Previous month",
"next": "Next month",
"person": "Person",
"none": "No events in this period.",
"activity": "{count} claims",
"kind": {
@@ -252,21 +387,42 @@
"hearing": "Hearing (inquiry committee)",
"government_meeting": "Council of Ministers"
},
"datePrecision": "Date = publication date of the source"
"datePrecision": "Date = publication date of the source",
"today": "Today",
"personPlaceholder": "Name",
"kindLabel": "Kind",
"allKinds": "All kinds",
"legend": "Legend",
"group": {
"parliament": "Parliament",
"government": "Government",
"travel": "Trips & meetings",
"public": "Public appearances"
},
"more": "+{count} more",
"activityHint": "All facts from this day"
},
"Former": {
"title": "What are former politicians up to?",
"intro": "Former office holders – sorted by most recent news coverage.",
"lastRole": "Last: {role}",
"latestNews": "Latest coverage",
"career": "Career"
"career": "Career",
"none": "No former politicians found."
},
"ClaimDetails": {
"with": "with",
"reference": "Ref.",
"article": "Article",
"fileNumber": "File no.",
"financeDetails": "Annual report {year} details"
"financeDetails": "Annual report {year} details",
"metric": {
"likes": "likes",
"reposts": "reposts",
"favourites": "favourites",
"reblogs": "boosts",
"views": "views"
}
},
"Money": {
"title": "Money",
@@ -301,7 +457,10 @@
"side_income": "Side income",
"sponsorship": "Sponsorship",
"other": "Other"
}
},
"flows": "Individual money flows",
"nameLabel": "Name",
"asReported": "Amounts as reported."
},
"Organizations": {
"title": "Organisations",
@@ -350,7 +509,14 @@
"authority": "Authority",
"eu_institution": "EU institution",
"other": "Other"
}
},
"receivedTotal": "received (total)",
"paidTotal": "paid (total)",
"counterpartyCount": "counterparties",
"receivedFromTitle": "Received money from",
"paidToTitle": "Paid to",
"noMoney": "No money flows recorded.",
"identifiers": "Identifiers"
},
"Network": {
"title": "Network: connected organisations",
@@ -366,7 +532,10 @@
"role": "office",
"lobbying": "lobbying",
"party": "party"
}
},
"organization": "Organisation",
"connection": "Connection",
"money": "This organisation's money"
},
"Social": {
"title": "Social media",
@@ -389,6 +558,44 @@
"network": "Network",
"article": "Article",
"change": "Change",
"note": "An edit from a network says nothing about which person made it. IP ranges: config/ip_ranges.yaml."
"note": "An edit from a network says nothing about which person made it.",
"ranges": "The IP ranges are listed in config/ip_ranges.yaml in the source code."
},
"Layout": {
"skip": "Skip to content",
"homeLabel": "Politrack – home",
"mainNav": "Main navigation",
"footerNav": "Site map",
"more": "More",
"menu": "Menu",
"searchLabel": "Search",
"searchPlaceholder": "Search …",
"filtersActive": "My filters: {count} active",
"themeLabel": "Colour scheme: {current} – switch to {next}",
"theme": {
"system": "system",
"light": "light",
"dark": "dark"
},
"group": {
"people": "People",
"decisions": "Decisions",
"influence": "Money & influence",
"about": "About Politrack"
}
},
"Footer": {
"tagline": "Austrian politics, sourced. An independent project that collects public information on politicians and parties – every fact with its source.",
"principleEvidence": "Every fact with its source",
"principleInnocence": "Presumption of innocence",
"principlePrivacy": "No ads, no tracking",
"developers": "Data, API & AI agents",
"bottom": "No liability for the data. Every fact links to its sources, which are authoritative."
},
"ClaimPage": {
"crumb": "Fact #{id}",
"cite": "Cite",
"citation": "Politrack: “{summary}”. Fact #{id}, retrieved {date}. {url}",
"filtersIgnored": "This page shows the fact with all its sources – regardless of your filters, so a shared link looks the same for everyone."
}
}
+91 -34
View File
@@ -1,66 +1,123 @@
import type {Metadata} from 'next';
import {notFound} from 'next/navigation';
import {getLocale, getTranslations, setRequestLocale} from 'next-intl/server';
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} from '@/lib/format';
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 loc = await getLocale();
let vote: VoteDetail;
try {
vote = await api.vote(id);
} catch (error) {
if (error instanceof NotFoundError) notFound();
throw error;
}
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>
<h1>{vote.item_title}</h1>
<p className="muted">
{formatDate(vote.voted_on, loc)} · {vote.chamber}
{vote.item_ref && <> · {vote.item_ref}</>}
{vote.stage && <> · {vote.stage}</>}
{vote.roll_call && <> · <strong>{t('rollCall')}</strong></>}
{' · '}<span className={`result result-${vote.result}`}>{vote.result !== 'unknown' && t(vote.result)}</span>
</p>
<p>
{vote.url && <a href={vote.url} target="_blank" rel="noopener noreferrer">{t('item')}</a>}
{vote.protocol_url && <> · <a href={vote.protocol_url} target="_blank" rel="noopener noreferrer">{t('protocol')}</a></>}
</p>
{vote.raw_text && <blockquote className="small">{vote.raw_text}</blockquote>}
<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>
<h2>{t('individualVotes')}</h2>
<div className="grid-2">
<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]) => (
<div key={faction}>
<h3>{faction}</h3>
<ul className="compact">
<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}>
<span className={`pos pos-${m.position}`}>{t(m.position as 'for')}</span>{' '}
<Link href={`/personen/${m.slug}`}>{m.full_name}</Link>
{m.dissent && <span className="dissent">{t('dissent')}</span>}
<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>
</div>
</li>
))}
</div>
</ul>
</section>
)}
{vote.raw_text && (
<details className="section card">
<summary className="strong">{t('protocolText')}</summary>
<blockquote className="small">{vote.raw_text}</blockquote>
</details>
)}
</article>
);
}
+22 -13
View File
@@ -1,35 +1,44 @@
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {VoteTable} from '@/components/VoteTable';
import {Link} from '@/i18n/navigation';
import {countActive, FilterBar} from '@/components/FilterBar';
import {PageHeader} from '@/components/PageHeader';
import {Pagination} from '@/components/Pagination';
import {VoteLegend, VoteList} from '@/components/VoteList';
import {api} from '@/lib/api';
const PAGE = 50;
type Props = {
params: Promise<{locale: string}>;
searchParams: Promise<{party?: string; page?: string; roll_call?: string}>;
};
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Votes'});
return {title: t('title')};
}
export default async function VotesPage({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
const sp = await searchParams;
const t = await getTranslations('Votes');
const page = Math.max(1, Number(sp.page) || 1);
const votes = await api.votes({party: sp.party, roll_call: sp.roll_call ? 'true' : undefined, limit: 50, offset: (page - 1) * 50});
const votes = await api.votes({party: sp.party, roll_call: sp.roll_call ? 'true' : undefined, limit: PAGE, offset: (page - 1) * PAGE});
return (
<>
<h1>{t('title')}</h1>
<form className="filters" method="get">
{sp.party && <input type="hidden" name="party" value={sp.party} />}
<label>
<PageHeader title={t('title')} lede={t('intro')} meta={sp.party ? t('filteredParty', {party: sp.party}) : undefined} />
<FilterBar id="votes" active={countActive(sp.roll_call)} resetHref={sp.party ? `/abstimmungen?party=${sp.party}` : '/abstimmungen'}
hidden={{party: sp.party}}>
<label className="field field--check">
<input type="checkbox" name="roll_call" value="1" defaultChecked={Boolean(sp.roll_call)} /> {t('onlyRollCall')}
</label>
<button type="submit">OK</button>
</form>
<VoteTable votes={votes} />
{votes.length === 50 && (
<p><Link href={{pathname: '/abstimmungen', query: {...sp, page: String(page + 1)}}}>→</Link></p>
)}
</FilterBar>
<VoteLegend />
<VoteList votes={votes} />
<Pagination pathname="/abstimmungen" query={sp} page={page} hasNext={votes.length === PAGE} />
</>
);
}
@@ -0,0 +1,63 @@
import type {Metadata} from 'next';
import {headers} from 'next/headers';
import {notFound} from 'next/navigation';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {ClaimCard} from '@/components/ClaimList';
import {Notice} from '@/components/Notice';
import {Breadcrumbs, type Crumb, SectionHead} from '@/components/PageHeader';
import {getPathname} from '@/i18n/navigation';
import {api, NotFoundError, type Claim} from '@/lib/api';
import {formatDate} from '@/lib/format';
type Props = {params: Promise<{locale: string; id: string}>};
async function loadClaim(id: string): Promise<Claim> {
if (!/^\d+$/.test(id)) notFound();
try {
return await api.claim(id);
} catch (error) {
if (error instanceof NotFoundError) notFound();
throw error;
}
}
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {id, locale} = await params;
const claim = await loadClaim(id);
const summary = locale === 'en' && claim.summary_en ? claim.summary_en : claim.summary_de;
return {title: summary.length > 70 ? `${summary.slice(0, 69)}…` : summary, description: summary};
}
/** One claim on its own page: all sources expanded and a ready-made citation (docs/design/README.md §7.17). */
export default async function ClaimPage({params}: Props) {
const {locale, id} = await params;
setRequestLocale(locale);
const t = await getTranslations('ClaimPage');
const claim = await loadClaim(id);
const summary = locale === 'en' && claim.summary_en ? claim.summary_en : claim.summary_de;
const h = await headers();
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'localhost';
const proto = h.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https');
const url = `${proto}://${host}${getPathname({href: `/angaben/${claim.id}`, locale})}`;
const today = formatDate(new Date().toISOString(), locale);
const crumbs: Crumb[] = [];
if (claim.person_slug) crumbs.push({label: claim.person_name ?? claim.person_slug, href: `/personen/${claim.person_slug}`});
else if (claim.organization_slug) {
crumbs.push({label: claim.organization_short ?? claim.organization_name ?? claim.organization_slug, href: `/organisationen/${claim.organization_slug}`});
}
crumbs.push({label: t('crumb', {id: String(claim.id)})});
return (
<article className="prose">
<Breadcrumbs items={crumbs} />
<ClaimCard claim={claim} standalone />
<section className="section" aria-labelledby="cite">
<SectionHead id="cite" title={t('cite')} />
<p className="citation">{t('citation', {summary, id: String(claim.id), date: today, url})}</p>
<Notice tone="note">{t('filtersIgnored')}</Notice>
</section>
</article>
);
}
+55 -40
View File
@@ -1,64 +1,79 @@
import {getLocale, getTranslations, setRequestLocale} from 'next-intl/server';
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {Avatar} from '@/components/Avatar';
import {StatusBadge} from '@/components/badges';
import {ExternalLink} from '@/components/ExternalLink';
import {countActive, Field, FilterBar} from '@/components/FilterBar';
import {EmptyState} from '@/components/Notice';
import {PageHeader} from '@/components/PageHeader';
import {Pagination} from '@/components/Pagination';
import {Link} from '@/i18n/navigation';
import {api} from '@/lib/api';
import {formatDate} from '@/lib/format';
const PAGE = 50;
type Props = {params: Promise<{locale: string}>; searchParams: Promise<{party?: string; page?: string}>};
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Former'});
return {title: t('title')};
}
export default async function FormerPage({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
const sp = await searchParams;
const t = await getTranslations('Former');
const tp = await getTranslations('Persons');
const loc = await getLocale();
const page = Math.max(1, Number(sp.page) || 1);
const [people, parties] = await Promise.all([
api.former({party: sp.party, limit: 50, offset: (page - 1) * 50}),
api.former({party: sp.party, limit: PAGE, offset: (page - 1) * PAGE}),
api.parties()
]);
return (
<>
<h1>{t('title')}</h1>
<p className="muted">{t('intro')}</p>
<form className="filters" method="get">
<select name="party" defaultValue={sp.party ?? ''}>
<option value="">{tp('all')}</option>
{parties.filter((p) => (p.total_people ?? 0) > 20).map((p) => (
<option key={p.slug} value={p.slug}>{p.short_name ?? p.name}</option>
<PageHeader title={t('title')} lede={t('intro')} />
<FilterBar id="former" active={countActive(sp.party)} resetHref="/ehemalige">
<Field label={tp('filterParty')}>
<select name="party" defaultValue={sp.party ?? ''}>
<option value="">{tp('all')}</option>
{parties.filter((p) => p.total_people > 20).map((p) => <option key={p.slug} value={p.slug}>{p.short_name ?? p.name}</option>)}
</select>
</Field>
</FilterBar>
{people.length === 0 ? <EmptyState icon="history">{t('none')}</EmptyState> : (
<ul className="claims">
{people.map((p) => (
<li key={p.slug}>
<article className="claim">
<div className="profile-head">
<Avatar name={p.full_name} src={p.photo_url} />
<div>
<div className="claim-head">
<Link href={`/personen/${p.slug}`}>{p.full_name}</Link>
<StatusBadge status={p.status} />
{p.last_role && <span>{t('lastRole', {role: p.last_role})}</span>}
{p.last_office_end && <span>{tp('lastOffice', {date: formatDate(p.last_office_end, locale)})}</span>}
</div>
{p.latest_news && (
<p className="claim-detail">
<span className="muted">{t('latestNews')} ({formatDate(p.latest_news_at, locale)}): </span>
{p.latest_news_url ? <ExternalLink href={p.latest_news_url}>{p.latest_news}</ExternalLink> : p.latest_news}
</p>
)}
{p.latest_career && <p className="claim-detail"><span className="muted">{t('career')}: </span>{p.latest_career}</p>}
</div>
</div>
</article>
</li>
))}
</select>
<button type="submit">OK</button>
</form>
<ul className="claims">
{people.map((p) => (
<li key={p.slug} className="claim">
<div className="claim-head">
<Link href={`/personen/${p.slug}`}><strong>{p.full_name}</strong></Link>
<span>{tp(`statusValue.${p.status}`)}</span>
{p.last_role && <span>{t('lastRole', {role: p.last_role})}</span>}
{p.last_office_end && <span>{tp('lastOffice', {date: formatDate(p.last_office_end, loc)})}</span>}
</div>
{p.latest_news && (
<p className="claim-summary">
<span className="muted small">{t('latestNews')} ({formatDate(p.latest_news_at, loc)}): </span>
{p.latest_news_url ? (
<a href={p.latest_news_url} target="_blank" rel="noopener noreferrer nofollow">{p.latest_news}</a>
) : (
p.latest_news
)}
</p>
)}
{p.latest_career && (
<p className="small"><span className="muted">{t('career')}: </span>{p.latest_career}</p>
)}
</li>
))}
</ul>
{people.length === 50 && (
<p><Link href={{pathname: '/ehemalige', query: {...sp, page: String(page + 1)}}}>→</Link></p>
</ul>
)}
<Pagination pathname="/ehemalige" query={sp} page={page} hasNext={people.length === PAGE} />
</>
);
}
+11 -4
View File
@@ -1,7 +1,15 @@
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {PageHeader} from '@/components/PageHeader';
import {SettingsForm} from '@/components/SettingsForm';
import {api, getPrefs} from '@/lib/api';
export async function generateMetadata({params}: {params: Promise<{locale: string}>}): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Settings'});
return {title: t('title'), robots: {index: false}};
}
export default async function SettingsPage({params}: {params: Promise<{locale: string}>}) {
const {locale} = await params;
setRequestLocale(locale);
@@ -9,10 +17,9 @@ export default async function SettingsPage({params}: {params: Promise<{locale: s
const [sources, topics, prefs] = await Promise.all([api.sources(), api.topics(), getPrefs()]);
return (
<>
<h1>{t('title')}</h1>
<p>{t('intro')}</p>
<div className="prose">
<PageHeader title={t('title')} lede={t('intro')} />
<SettingsForm sources={sources} topics={topics} initial={prefs} />
</>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
'use client';
import {useTranslations} from 'next-intl';
import {Icon} from '@/components/Icon';
import {Link} from '@/i18n/navigation';
/** Shown when a page fails to load (usually the API is unreachable). */
export default function ErrorPage({reset}: {error: Error & {digest?: string}; reset: () => void}) {
const t = useTranslations('Common');
return (
<div className="empty-state" role="alert">
<Icon name="alert" />
<h1>{t('errorTitle')}</h1>
<p>{t('loadError')}</p>
<p className="cluster">
<button type="button" onClick={() => reset()}>{t('retry')}</button>
<Link href="/" className="button secondary">{t('home')}</Link>
</p>
</div>
);
}
+55 -43
View File
@@ -1,8 +1,14 @@
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {BarList} from '@/components/charts';
import {countActive, Field, FilterBar} from '@/components/FilterBar';
import {MoneyTable} from '@/components/MoneyTable';
import {Notice} from '@/components/Notice';
import {PageHeader, SectionHead} from '@/components/PageHeader';
import {Pagination} from '@/components/Pagination';
import {Link} from '@/i18n/navigation';
import {api} from '@/lib/api';
import {formatMoney} from '@/lib/format';
import {api, type MoneySummaryRow} from '@/lib/api';
import {formatMoneyCompact} from '@/lib/format';
const KINDS = ['donation', 'party_funding', 'contract', 'government_ad', 'subsidy', 'political_ad', 'party_ad',
'sponsorship', 'loan', 'side_income'] as const;
@@ -14,6 +20,12 @@ type Props = {
min?: string; page?: string}>;
};
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Money'});
return {title: t('title')};
}
export default async function MoneyPage({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
@@ -24,60 +36,60 @@ export default async function MoneyPage({params, searchParams}: Props) {
const [flows, summary] = await Promise.all([
api.money({kinds: sp.kind, q: sp.q, org: sp.org, person: sp.person, since: sp.since, until: sp.until,
min_amount: sp.min, limit: PAGE, offset: (page - 1) * PAGE}),
filtered ? Promise.resolve([]) : api.moneySummary({kinds: sp.kind})
filtered ? Promise.resolve([] as MoneySummaryRow[]) : api.moneySummary({kinds: sp.kind})
]);
const payers = summary.filter((row) => row.side === 'payer').slice(0, 15);
const payees = summary.filter((row) => row.side === 'payee').slice(0, 15);
const rows = (side: 'payer' | 'payee') => summary.filter((row) => row.side === side).slice(0, 10).map((row) => ({
key: `${row.slug}-${row.kind}`,
label: <Link href={`/organisationen/${row.slug}`}>{row.name}</Link>,
value: row.total ?? 0,
display: row.total ? formatMoneyCompact(row.total, locale) : `${row.count}×`,
meta: t(`kind.${row.kind}` as 'kind.other')
}));
const payers = rows('payer');
const payees = rows('payee');
return (
<>
<h1>{t('title')}</h1>
<p className="muted">{t('intro')}</p>
<form className="filters" method="get">
<select name="kind" defaultValue={sp.kind ?? ''} aria-label={t('kindLabel')}>
<option value="">{t('allKinds')}</option>
{KINDS.map((kind) => <option key={kind} value={kind}>{t(`kind.${kind}`)}</option>)}
</select>
<input type="search" name="q" defaultValue={sp.q} placeholder={t('searchPlaceholder')} />
<label>{t('from')} <input type="date" name="since" defaultValue={sp.since} /></label>
<label>{t('to')} <input type="date" name="until" defaultValue={sp.until} /></label>
<label>{t('minAmount')} <input type="number" name="min" min={0} step={1000} defaultValue={sp.min} /></label>
{sp.org && <input type="hidden" name="org" value={sp.org} />}
{sp.person && <input type="hidden" name="person" value={sp.person} />}
<button type="submit">OK</button>
</form>
<PageHeader title={t('title')} lede={t('intro')} />
<FilterBar id="money" active={countActive(sp.kind, sp.q, sp.since, sp.until, sp.min)} resetHref="/geld"
hidden={{org: sp.org, person: sp.person}}>
<Field label={t('kindLabel')}>
<select name="kind" defaultValue={sp.kind ?? ''}>
<option value="">{t('allKinds')}</option>
{KINDS.map((kind) => <option key={kind} value={kind}>{t(`kind.${kind}`)}</option>)}
</select>
</Field>
<Field label={t('nameLabel')} wide><input type="search" name="q" defaultValue={sp.q} placeholder={t('searchPlaceholder')} /></Field>
<Field label={t('from')}><input type="date" name="since" defaultValue={sp.since} /></Field>
<Field label={t('to')}><input type="date" name="until" defaultValue={sp.until} /></Field>
<Field label={t('minAmount')}><input type="number" name="min" min={0} step={1000} defaultValue={sp.min} inputMode="numeric" /></Field>
</FilterBar>
{(sp.org || sp.person) && (
<p className="small">
<Notice tone="info">
{t('filteredBy')}: <strong>{sp.org ?? sp.person}</strong>
{sp.person && <> ({t('personNote')})</>} · <Link href="/geld">{t('reset')}</Link>
</p>
</Notice>
)}
{!filtered && summary.length > 0 && (
{!filtered && (payers.length > 0 || payees.length > 0) && (
<div className="grid-2">
{([{label: 'topPayers', rows: payers}, {label: 'topPayees', rows: payees}] as const).map(({label, rows}) => (
<section key={label}>
<h2>{t(label)}</h2>
<ol className="compact">
{rows.map((row) => (
<li key={`${row.slug}-${row.kind}`}>
<Link href={`/organisationen/${row.slug}`}>{row.name}</Link>{' '}
<span className="muted small">
{t(`kind.${row.kind}` as 'kind.other')} · {row.total ? formatMoney(row.total, locale) : `${row.count}×`}
</span>
</li>
))}
</ol>
</section>
))}
<section aria-labelledby="top-payers">
<SectionHead id="top-payers" title={t('topPayers')} />
<BarList rows={payers} label={t('topPayers')} />
</section>
<section aria-labelledby="top-payees">
<SectionHead id="top-payees" title={t('topPayees')} />
<BarList rows={payees} label={t('topPayees')} />
</section>
</div>
)}
<MoneyTable flows={flows} />
{flows.length === PAGE && (
<p><Link href={{pathname: '/geld', query: {...sp, page: String(page + 1)}}}>→</Link></p>
)}
<p className="small muted">{t('sourcesNote')}</p>
<section className={filtered ? undefined : 'section'} aria-labelledby="flows">
<SectionHead id="flows" title={t('flows')} />
<MoneyTable flows={flows} />
<Pagination pathname="/geld" query={sp} page={page} hasNext={flows.length === PAGE} />
</section>
<Notice tone="note">{t('sourcesNote')}</Notice>
</>
);
}
+89 -60
View File
@@ -1,6 +1,13 @@
import {getLocale, getTranslations, setRequestLocale} from 'next-intl/server';
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {ExternalLink} from '@/components/ExternalLink';
import {countActive, Field, FilterBar} from '@/components/FilterBar';
import {Icon} from '@/components/Icon';
import {Notice} from '@/components/Notice';
import {PageHeader} from '@/components/PageHeader';
import {Link} from '@/i18n/navigation';
import {api, type CalendarEvent} from '@/lib/api';
import {formatMonth, intlLocale} from '@/lib/format';
type Props = {
params: Promise<{locale: string}>;
@@ -10,12 +17,12 @@ type Props = {
const KINDS = ['parliament_session', 'committee', 'hearing', 'government_meeting', 'press_conference', 'foreign_trip',
'meeting', 'public_event'] as const;
function monthBounds(month: string) {
const [y, m] = month.split('-').map(Number);
const first = new Date(Date.UTC(y, m - 1, 1));
const last = new Date(Date.UTC(y, m, 0));
return {first, last};
}
// Four stripe colours for the event kinds (always with the kind's name; see the legend).
const GROUPS: Record<string, 'parliament' | 'government' | 'travel' | 'public'> = {
parliament_session: 'parliament', committee: 'parliament', hearing: 'parliament',
government_meeting: 'government', press_conference: 'government',
foreign_trip: 'travel', meeting: 'travel'
};
function iso(d: Date) {
return d.toISOString().slice(0, 10);
@@ -27,14 +34,22 @@ function shiftMonth(month: string, delta: number) {
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Calendar'});
return {title: t('title')};
}
export default async function CalendarPage({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
const sp = await searchParams;
const t = await getTranslations('Calendar');
const loc = await getLocale();
const month = /^\d{4}-\d{2}$/.test(sp.month ?? '') ? sp.month! : new Date().toISOString().slice(0, 7);
const {first, last} = monthBounds(month);
const today = iso(new Date());
const month = /^\d{4}-\d{2}$/.test(sp.month ?? '') ? sp.month! : today.slice(0, 7);
const [y, m] = month.split('-').map(Number);
const first = new Date(Date.UTC(y, m - 1, 1));
const last = new Date(Date.UTC(y, m, 0));
const kinds = sp.kind && (KINDS as readonly string[]).includes(sp.kind) ? [sp.kind] : undefined;
const data = await api.calendar({start: iso(first), end: iso(last), person: sp.person, party: sp.party, kinds});
@@ -50,74 +65,88 @@ export default async function CalendarPage({params, searchParams}: Props) {
// Monday-first grid.
const lead = (first.getUTCDay() + 6) % 7;
const cells: (Date | null)[] = [...Array(lead).fill(null)];
for (let day = 1; day <= last.getUTCDate(); day++) cells.push(new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), day)));
const weekdays = [...Array(7)].map((_, i) =>
new Date(Date.UTC(2024, 0, 1 + i)).toLocaleDateString(loc === 'de' ? 'de-AT' : 'en-GB', {weekday: 'short'})
);
for (let day = 1; day <= last.getUTCDate(); day++) cells.push(new Date(Date.UTC(y, m - 1, day)));
const weekday = (d: Date, style: 'short' | 'long') => d.toLocaleDateString(intlLocale(locale), {weekday: style, timeZone: 'UTC'});
const weekdays = [...Array(7)].map((_, i) => weekday(new Date(Date.UTC(2024, 0, 1 + i)), 'short'));
const query = {person: sp.person, party: sp.party, kind: sp.kind};
const title = first.toLocaleDateString(loc === 'de' ? 'de-AT' : 'en-GB', {month: 'long', year: 'numeric', timeZone: 'UTC'});
const kindLabel = (kind: string) => t(`kind.${(KINDS as readonly string[]).includes(kind) ? kind : 'other'}` as 'kind.other');
return (
<>
<h1>{t('title')}: {title}</h1>
<p className="muted">{t('intro')}</p>
<form className="filters" method="get">
<input type="month" name="month" defaultValue={month} />
<input type="text" name="person" defaultValue={sp.person} placeholder={t('person')} />
<select name="kind" defaultValue={sp.kind ?? ''}>
<option value="">–</option>
{KINDS.map((k) => (
<option key={k} value={k}>{t(`kind.${k}`)}</option>
))}
</select>
<button type="submit">OK</button>
</form>
<p>
<Link href={{pathname: '/kalender', query: {...query, month: shiftMonth(month, -1)}}}>{t('prev')}</Link>
{' · '}
<Link href={{pathname: '/kalender', query: {...query, month: shiftMonth(month, 1)}}}>{t('next')}</Link>
</p>
<PageHeader
title={`${t('title')}: ${formatMonth(month, locale)}`}
lede={t('intro')}
actions={
<>
<Link className="button secondary" href={{pathname: '/kalender', query: {...query, month: shiftMonth(month, -1)}}}>
<Icon name="arrow-left" /> {t('prev')}
</Link>
{month !== today.slice(0, 7) && (
<Link className="button secondary" href={{pathname: '/kalender', query}}>{t('today')}</Link>
)}
<Link className="button secondary" href={{pathname: '/kalender', query: {...query, month: shiftMonth(month, 1)}}}>
{t('next')} <Icon name="arrow-right" />
</Link>
</>
}
/>
<FilterBar id="calendar" active={countActive(sp.person, sp.kind)} resetHref={`/kalender?month=${month}`} hidden={{month, party: sp.party}}>
<Field label={t('person')} wide><input type="text" name="person" defaultValue={sp.person} placeholder={t('personPlaceholder')} /></Field>
<Field label={t('kindLabel')}>
<select name="kind" defaultValue={sp.kind ?? ''}>
<option value="">{t('allKinds')}</option>
{KINDS.map((k) => <option key={k} value={k}>{t(`kind.${k}`)}</option>)}
</select>
</Field>
</FilterBar>
<div className="calendar">
{weekdays.map((w) => (
<div key={w} className="calendar-head">{w}</div>
<ul className="legend" aria-label={t('legend')}>
{(['parliament', 'government', 'travel', 'public'] as const).map((group) => (
<li key={group} className={`event--${group}`}><span className="event-swatch" /> {t(`group.${group}`)}</li>
))}
</ul>
<div className="calendar section" style={{marginTop: 'var(--space-4)'}}>
{weekdays.map((w) => <div key={w} className="calendar-head" aria-hidden="true">{w}</div>)}
{cells.map((cell, i) => {
if (!cell) return <div key={`empty-${i}`} className="calendar-cell empty" />;
if (!cell) return <div key={`blank-${i}`} className="calendar-cell is-blank" aria-hidden="true" />;
const day = iso(cell);
const events = byDay.get(day) ?? [];
const count = activity.get(day) ?? 0;
const weekend = cell.getUTCDay() === 0 || cell.getUTCDay() === 6;
const classes = ['calendar-cell', events.length && 'has-events', weekend && 'is-weekend', day === today && 'is-today']
.filter(Boolean).join(' ');
return (
<div key={day} className={`calendar-cell${events.length ? ' has-events' : ''}`}>
<section key={day} className={classes} aria-label={cell.toLocaleDateString(intlLocale(locale), {dateStyle: 'full', timeZone: 'UTC'})}>
<div className="calendar-day">
<strong>{cell.getUTCDate()}</strong>
<span className="calendar-day-num">{cell.getUTCDate()}</span>
<span className="calendar-day-name">{weekday(cell, 'long')}</span>
{count > 0 && (
<Link className="small muted" href={{pathname: '/suche', query: {since: day, until: day, q: sp.person ?? ''}}}>
{t('activity', {count})}
</Link>
<Link href={{pathname: '/suche', query: {since: day, until: day}}} title={t('activityHint')}>{t('activity', {count})}</Link>
)}
</div>
<ul>
{events.slice(0, 6).map((ev) => (
<li key={ev.id} className={`ev ev-${ev.kind}`}>
<span className="ev-kind">{t(`kind.${(KINDS as readonly string[]).includes(ev.kind) ? ev.kind : 'other'}` as 'kind.other')}</span>{' '}
{ev.url ? <a href={ev.url} target="_blank" rel="noopener noreferrer">{ev.title}</a> : ev.title}
{ev.participants.length > 0 && (
<div className="small">
{ev.participants.map((p) => (
<Link key={p.slug} href={`/personen/${p.slug}`}>{p.name}</Link>
))}
</div>
)}
</li>
))}
{events.length > 6 && <li className="small muted">+{events.length - 6}</li>}
</ul>
</div>
{events.length > 0 && (
<ul className="calendar-events">
{events.slice(0, 6).map((ev) => (
<li key={ev.id} className={`event event--${GROUPS[ev.kind] ?? 'public'}`}>
<span className="event-kind">{kindLabel(ev.kind)}</span>
{ev.url ? <ExternalLink href={ev.url} icon={false}>{ev.title}</ExternalLink> : ev.title}
{ev.participants.length > 0 && (
<span className="event-people">
{ev.participants.slice(0, 4).map((p) => <Link key={p.slug} href={`/personen/${p.slug}`}>{p.name}</Link>)}
{ev.participants.length > 4 && <span>+{ev.participants.length - 4}</span>}
</span>
)}
</li>
))}
{events.length > 6 && <li className="muted">{t('more', {count: events.length - 6})}</li>}
</ul>
)}
</section>
);
})}
</div>
{data.events.length === 0 && <p className="muted">{t('none')}</p>}
{data.events.length === 0 && <Notice tone="note">{t('none')}</Notice>}
<p className="small muted">{t('datePrecision')}</p>
</>
);
+26 -27
View File
@@ -1,12 +1,17 @@
import type {Metadata} from 'next';
import type {Metadata, Viewport} from 'next';
import {cookies} from 'next/headers';
import {notFound} from 'next/navigation';
import {Suspense} from 'react';
import {hasLocale, NextIntlClientProvider} from 'next-intl';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {LocaleSwitcher} from '@/components/LocaleSwitcher';
import {Link} from '@/i18n/navigation';
import {SiteFooter} from '@/components/SiteFooter';
import {SiteHeader} from '@/components/SiteHeader';
import {routing} from '@/i18n/routing';
import '../globals.css';
import {activeFilterCount, parsePrefs, parseTheme, PREFS_COOKIE, THEME_COOKIE} from '@/lib/prefs';
import '@/styles/tokens.css';
import '@/styles/base.css';
import '@/styles/layout.css';
import '@/styles/components.css';
import '@/styles/print.css';
type Props = {children: React.ReactNode; params: Promise<{locale: string}>};
@@ -20,36 +25,30 @@ export async function generateMetadata({params}: Props): Promise<Metadata> {
return {title: {default: t('title'), template: '%s · Politrack'}, description: t('description')};
}
export const viewport: Viewport = {
themeColor: [
{media: '(prefers-color-scheme: light)', color: '#f6f5f1'},
{media: '(prefers-color-scheme: dark)', color: '#131311'}
]
};
export default async function LocaleLayout({children, params}: Props) {
const {locale} = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale);
const t = await getTranslations('Nav');
const t = await getTranslations('Layout');
const store = await cookies();
const theme = parseTheme(store.get(THEME_COOKIE)?.value);
const filterCount = activeFilterCount(parsePrefs(store.get(PREFS_COOKIE)?.value));
return (
<html lang={locale}>
<html lang={locale} data-theme={theme === 'system' ? undefined : theme}>
<body>
<NextIntlClientProvider>
<header className="site-header">
<Link href="/" className="brand">Politrack</Link>
<nav>
<Link href="/personen">{t('persons')}</Link>
<Link href="/parteien">{t('parties')}</Link>
<Link href="/organisationen">{t('organizations')}</Link>
<Link href="/geld">{t('money')}</Link>
<Link href="/abstimmungen">{t('votes')}</Link>
<Link href="/social">{t('social')}</Link>
<Link href="/kalender">{t('calendar')}</Link>
<Link href="/ehemalige">{t('former')}</Link>
<Link href="/suche">{t('search')}</Link>
<Link href="/einstellungen">{t('settings')}</Link>
<Link href="/methodik">{t('method')}</Link>
<Suspense>
<LocaleSwitcher />
</Suspense>
</nav>
</header>
<main>{children}</main>
<a href="#main" className="skip-link">{t('skip')}</a>
<SiteHeader theme={theme} filterCount={filterCount} />
<main id="main" className="page">{children}</main>
<SiteFooter />
</NextIntlClientProvider>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
import {useTranslations} from 'next-intl';
/** Skeleton in the shape of an index page while the server waits for the API. */
export default function Loading() {
const t = useTranslations('Common');
return (
<div aria-busy="true" aria-live="polite">
<span className="visually-hidden">{t('loading')}</span>
<div className="skeleton" style={{height: '2.25rem', width: '40%', marginBottom: 'var(--space-3)'}} />
<div className="skeleton" style={{height: '1.25rem', width: '65%', marginBottom: 'var(--space-6)'}} />
{[0, 1, 2, 3].map((i) => (
<div key={i} className="skeleton" style={{height: '7rem', marginBottom: 'var(--space-3)'}} />
))}
</div>
);
}
+121 -24
View File
@@ -1,36 +1,133 @@
import type {CSSProperties} from 'react';
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {api} from '@/lib/api';
import {SourceMeter, StatusBadge} from '@/components/badges';
import {ExternalLink} from '@/components/ExternalLink';
import {Notice} from '@/components/Notice';
import {PageHeader} from '@/components/PageHeader';
import {api, type LegalStage} from '@/lib/api';
const LEGAL_STAGES: LegalStage[] = ['allegation_reported', 'investigation', 'immunity_waiver_requested', 'immunity_waived',
'immunity_refused', 'indicted', 'trial', 'convicted_not_final', 'convicted_final', 'acquitted', 'proceedings_dropped', 'diversion'];
const SECTIONS = ['claims', 'certainty', 'legal', 'sources', 'notCollected', 'data', 'corrections'] as const;
const ANCHORS: Record<(typeof SECTIONS)[number], string> = {
claims: 'angaben', certainty: 'sicherheit', legal: 'verfahren', sources: 'quellen', notCollected: 'nicht-erfasst',
data: 'daten', corrections: 'korrekturen'
};
export async function generateMetadata({params}: {params: Promise<{locale: string}>}): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Method'});
return {title: t('title')};
}
export default async function MethodPage({params}: {params: Promise<{locale: string}>}) {
const {locale} = await params;
setRequestLocale(locale);
const t = await getTranslations('Method');
const sources = await api.sources();
const tc = await getTranslations('Claims');
const ts = await getTranslations('Settings');
const tsocial = await getTranslations('Social');
const sources = (await api.sources()).sort((a, b) => b.default_trust - a.default_trust || a.name.localeCompare(b.name));
return (
<article className="prose">
<h1>{t('title')}</h1>
<p>{t('p1')}</p>
<p>{t('p2')}</p>
<p>{t('p3')}</p>
<table>
<thead>
<tr><th></th><th>{t('license')}</th><th>{t('access')}</th><th>{t('trust')}</th></tr>
</thead>
<tbody>
{sources.map((s) => (
<tr key={s.slug}>
<td>
{s.homepage ? <a href={s.homepage} target="_blank" rel="noopener noreferrer">{s.name}</a> : s.name}
{s.tos_notes && <div className="muted small">{s.tos_notes}</div>}
</td>
<td className="small">{s.license}</td>
<td className="small">{s.access_method}</td>
<td className="num">{s.default_trust}</td>
<article>
<PageHeader title={t('title')} lede={t('intro')} />
<div className="layout-aside">
<div className="prose">
<section id={ANCHORS.claims}>
<h2>{t('section.claims')}</h2>
<p>{t('p1')}</p>
<p>{t('p2')}</p>
</section>
<section id={ANCHORS.certainty}>
<h2>{t('section.certainty')}</h2>
<p>{t('certaintyIntro')}</p>
<dl className="meta-list">
<dt><SourceMeter count={1} status="reported" /></dt><dd>{t('reported')}</dd>
<dt><SourceMeter count={3} status="corroborated" /></dt><dd>{t('corroborated')}</dd>
<dt><SourceMeter count={2} status="disputed" /></dt><dd>{t('disputed')}</dd>
<dt><StatusBadge status="active" /></dt><dd>{t('activeVerified')}</dd>
<dt><StatusBadge status="active" verified={false} /></dt><dd>{t('activeUnverified')}</dd>
<dt><span className="badge badge--dashed">{tsocial('unverified')}</span></dt><dd>{t('accountUnverified')}</dd>
</dl>
</section>
<section id={ANCHORS.legal}>
<h2>{t('section.legal')}</h2>
<p>{t('p3')}</p>
<Notice tone="legal">{tc('innocence')}</Notice>
<dl className="meta-list">
{LEGAL_STAGES.map((stage) => (
<div key={stage} style={{display: 'contents'}}>
<dt><span className="badge badge--caution">{tc(`legalStage.${stage}`)}</span></dt>
<dd>{t(`stage.${stage}`)}</dd>
</div>
))}
</dl>
</section>
<section id={ANCHORS.sources}>
<h2>{t('section.sources')}</h2>
<p>{t('sourcesIntro')}</p>
</section>
</div>
<aside>
<nav className="panel" aria-labelledby="toc">
<h2 id="toc">{t('toc')}</h2>
<ol className="link-list">
{SECTIONS.map((key) => <li key={key}><a href={`#${ANCHORS[key]}`}>{t(`section.${key}`)}</a></li>)}
</ol>
</nav>
</aside>
</div>
<div className="table-wrap">
<table className="table-stack">
<thead>
<tr>
<th scope="col">{t('source')}</th><th scope="col">{t('kind')}</th><th scope="col">{t('license')}</th>
<th scope="col">{t('access')}</th><th scope="col">{t('trust')}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{sources.map((s) => (
<tr key={s.slug}>
<td data-label={t('source')}>
{s.homepage ? <ExternalLink href={s.homepage}>{s.name}</ExternalLink> : <strong>{s.name}</strong>}
{s.tos_notes && <span className="block muted xsmall">{s.tos_notes}</span>}
</td>
<td data-label={t('kind')}><span className="badge">{ts(`kind.${s.kind}` as 'kind.official')}</span></td>
<td data-label={t('license')} className="small">{s.license ?? '–'}</td>
<td data-label={t('access')} className="small">{s.access_method}</td>
<td data-label={t('trust')}>
<span className="trust-meter">
<span><span style={{'--value': `${s.default_trust}%`} as CSSProperties} /></span>{s.default_trust}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="prose">
<section id={ANCHORS.notCollected}>
<h2>{t('section.notCollected')}</h2>
<p>{t('notCollected')}</p>
<p>{tsocial('policy')}</p>
</section>
<section id={ANCHORS.data}>
<h2>{t('section.data')}</h2>
<p>{t('data')}</p>
</section>
<section id={ANCHORS.corrections}>
<h2>{t('section.corrections')}</h2>
<p>{t('corrections')}</p>
</section>
</div>
</article>
);
}
+14 -1
View File
@@ -1,6 +1,19 @@
import {useTranslations} from 'next-intl';
import {Icon} from '@/components/Icon';
import {Link} from '@/i18n/navigation';
export default function NotFound() {
const t = useTranslations('Common');
return <p className="muted">{t('notFound')}</p>;
return (
<div className="empty-state">
<Icon name="search" />
<h1>{t('notFoundTitle')}</h1>
<p>{t('notFound')}</p>
<p className="cluster">
<Link href="/" className="button">{t('home')}</Link>
<Link href="/personen" className="button secondary">{t('persons')}</Link>
<Link href="/suche" className="button secondary">{t('search')}</Link>
</p>
</div>
);
}
@@ -1,11 +1,15 @@
import type {Metadata} from 'next';
import {notFound} from 'next/navigation';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {BarList, StatGrid} from '@/components/charts';
import {ClaimList} from '@/components/ClaimList';
import {ExternalLink} from '@/components/ExternalLink';
import {MoneyTable} from '@/components/MoneyTable';
import {Notice} from '@/components/Notice';
import {Breadcrumbs, SectionHead} from '@/components/PageHeader';
import {Link, redirect} from '@/i18n/navigation';
import {api, NotFoundError, type OrganizationDetail} from '@/lib/api';
import {formatMoney} from '@/lib/format';
import {api, NotFoundError, type Counterparty, type OrganizationDetail} from '@/lib/api';
import {formatMoneyCompact, formatNumber} from '@/lib/format';
type Props = {params: Promise<{locale: string; slug: string}>};
@@ -14,6 +18,12 @@ const IDENTIFIER_LINKS: Record<string, (value: string) => string> = {
eu_transparency: (v) => `https://transparency-register.europa.eu/searchregister-or-update/organisation-detail_en?id=${v}`
};
function years(first: string | null, last: string | null): string {
const a = first?.slice(0, 4);
const b = last?.slice(0, 4);
return a && b && a !== b ? `${a}–${b}` : a ?? b ?? '';
}
async function loadOrganization(slug: string): Promise<OrganizationDetail> {
try {
return await api.organization(slug);
@@ -38,89 +48,133 @@ export default async function OrganizationPage({params}: Props) {
const tm = await getTranslations('Money');
const [flows, claims] = await Promise.all([
api.money({org: slug, limit: 50}),
api.claims({organization: slug, limit: 50})
api.claims({organization: slug, limit: 30})
]);
const total = (direction: 'paid' | 'received') =>
org.money.filter((m) => m.direction === direction).reduce((acc, m) => acc + (m.total ?? 0), 0);
const paid = total('paid');
const received = total('received');
const counterpartyRows = (direction: Counterparty['direction']) => org.counterparties
.filter((c) => c.direction === direction)
.slice(0, 10)
.map((c) => ({
key: `${c.slug}-${c.money_kind}`,
label: <Link href={`/organisationen/${c.slug}`}>{c.name}</Link>,
value: c.total ?? 0,
display: c.total ? formatMoneyCompact(c.total, locale) : `${c.count}×`,
meta: tm(`kind.${c.money_kind}` as 'kind.other')
}));
const paidTo = counterpartyRows('paid_to');
const receivedFrom = counterpartyRows('received_from');
const identifiers = Object.entries(org.identifiers ?? {});
return (
<article>
<h1>{org.name} {org.short_name && org.short_name !== org.name && <span className="muted small">{org.short_name}</span>}</h1>
<p className="muted">
{t(`kind.${org.kind}` as 'kind.other')}
{org.parent_slug && <> · {t('partOf')} <Link href={`/organisationen/${org.parent_slug}`}>{org.parent_name}</Link></>}
</p>
{org.identifiers && Object.keys(org.identifiers).length > 0 && (
<p className="small">
{Object.entries(org.identifiers).map(([scheme, value]) => (
<span key={scheme} className="identifier">
{t(`identifier.${scheme}` as 'identifier.other')}:{' '}
{IDENTIFIER_LINKS[scheme] ? <a href={IDENTIFIER_LINKS[scheme](value)} target="_blank" rel="noopener noreferrer">{value}</a> : value}
</span>
))}
</p>
)}
<Breadcrumbs items={[{label: t('title'), href: '/organisationen'}, {label: org.short_name ?? org.name}]} />
<header className="page-header">
<div className="page-header-main">
<p className="eyebrow">{t(`kind.${org.kind}` as 'kind.other')}</p>
<h1>{org.name}</h1>
{(org.short_name && org.short_name !== org.name) || org.parent_slug ? (
<p className="page-header-meta">
{org.short_name && org.short_name !== org.name && <>{org.short_name}</>}
{org.parent_slug && <> · {t('partOf')} <Link href={`/organisationen/${org.parent_slug}`}>{org.parent_name}</Link></>}
</p>
) : null}
</div>
</header>
<div className="grid-2">
<section>
<h2>{t('moneyOverview')}</h2>
{org.money.length === 0 ? <p className="muted">–</p> : (
<table>
<tbody>
{org.money.map((m) => (
<tr key={`${m.kind}-${m.direction}`}>
<td>{tm(`kind.${m.kind}` as 'kind.other')}</td>
<td className="muted small">{t(m.direction)}</td>
<td className="num">{m.total ? formatMoney(m.total, locale) : ''}</td>
<td className="num muted small">{m.count}×</td>
<td className="muted small">{m.first?.slice(0, 4)}–{m.last?.slice(0, 4)}</td>
</tr>
<div className="layout-aside">
<div>
{(paid > 0 || received > 0 || org.counterparties.length > 0) && (
<StatGrid
items={[
...(received > 0 ? [{label: t('receivedTotal'), value: formatMoneyCompact(received, locale)}] : []),
...(paid > 0 ? [{label: t('paidTotal'), value: formatMoneyCompact(paid, locale)}] : []),
{label: t('counterpartyCount'), value: formatNumber(org.counterparties.length, locale)}
]}
/>
)}
{(paidTo.length > 0 || receivedFrom.length > 0) && (
<div className="grid-2">
{receivedFrom.length > 0 && (
<section aria-labelledby="received-from">
<SectionHead id="received-from" title={t('receivedFromTitle')} />
<BarList rows={receivedFrom} label={t('receivedFromTitle')} />
</section>
)}
{paidTo.length > 0 && (
<section aria-labelledby="paid-to">
<SectionHead id="paid-to" title={t('paidToTitle')} />
<BarList rows={paidTo} label={t('paidToTitle')} />
</section>
)}
</div>
)}
{org.people.length > 0 && (
<section className="section" aria-labelledby="people">
<SectionHead id="people" title={t('people')} />
<Notice tone="note">{t('connectionNote')}</Notice>
<ul className="link-list">
{org.people.map((p, i) => (
<li key={`${p.slug}-${i}`}>
<Link href={`/personen/${p.slug}`}>{p.full_name}</Link>{' '}
<span className="muted small">{p.summary_de}</span>
</li>
))}
</tbody>
</table>
</ul>
</section>
)}
</section>
<section>
<h2>{t('counterparties')}</h2>
{org.counterparties.length === 0 ? <p className="muted">–</p> : (
<ul className="compact">
{org.counterparties.map((c) => (
<li key={`${c.slug}-${c.money_kind}-${c.direction}`}>
<span className="muted small">{t(c.direction)}</span>{' '}
<Link href={`/organisationen/${c.slug}`}>{c.name}</Link>{' '}
<span className="muted small">
{tm(`kind.${c.money_kind}` as 'kind.other')} · {c.total ? formatMoney(c.total, locale) : `${c.count}×`}
</span>
</li>
))}
</ul>
<section className="section" aria-labelledby="flows">
<SectionHead id="flows" title={t('flows')} more={flows.length === 50 ? {href: `/geld?org=${slug}`, label: t('allFlows')} : undefined} />
<MoneyTable flows={flows} self={slug} />
<p className="small muted">{tm('asReported')}</p>
</section>
<section className="section" aria-labelledby="claims">
<SectionHead id="claims" title={t('claims')} />
<ClaimList claims={claims} />
</section>
</div>
<aside>
<section className="panel" aria-labelledby="money-overview">
<h2 id="money-overview">{t('moneyOverview')}</h2>
{org.money.length === 0 ? <p className="muted small">{t('noMoney')}</p> : (
<ul className="link-list">
{org.money.map((m) => (
<li key={`${m.kind}-${m.direction}`}>
<strong>{tm(`kind.${m.kind}` as 'kind.other')}</strong> <span className="muted">({t(m.direction)})</span>
<span className="block small">
<span className="amount">{m.total ? formatMoneyCompact(m.total, locale) : '–'}</span>
<span className="muted"> · {m.count}× · {years(m.first, m.last)}</span>
</span>
</li>
))}
</ul>
)}
</section>
{identifiers.length > 0 && (
<section className="panel" aria-labelledby="identifiers">
<h2 id="identifiers">{t('identifiers')}</h2>
<dl className="meta-list">
{identifiers.map(([scheme, value]) => (
<div key={scheme} style={{display: 'contents'}}>
<dt>{t(`identifier.${scheme}` as 'identifier.other')}</dt>
<dd className="mono">
{IDENTIFIER_LINKS[scheme] ? <ExternalLink href={IDENTIFIER_LINKS[scheme](value)}>{value}</ExternalLink> : value}
</dd>
</div>
))}
</dl>
</section>
)}
</section>
</aside>
</div>
{org.people.length > 0 && (
<section>
<h2>{t('people')}</h2>
<p className="notice small">{t('connectionNote')}</p>
<ul className="compact">
{org.people.map((p, i) => (
<li key={`${p.slug}-${i}`}>
<Link href={`/personen/${p.slug}`}>{p.full_name}</Link>{' '}
<span className="muted small">{p.summary_de}</span>
</li>
))}
</ul>
</section>
)}
<section>
<h2>{t('flows')}</h2>
<MoneyTable flows={flows} />
{flows.length === 50 && <p><Link href={`/geld?org=${slug}`}>→ {t('allFlows')}</Link></p>}
</section>
<section>
<h2>{t('claims')}</h2>
<ClaimList claims={claims} />
</section>
</article>
);
}
+46 -32
View File
@@ -1,61 +1,75 @@
import {getLocale, getTranslations, setRequestLocale} from 'next-intl/server';
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {countActive, Field, FilterBar} from '@/components/FilterBar';
import {EmptyState} from '@/components/Notice';
import {PageHeader} from '@/components/PageHeader';
import {Pagination} from '@/components/Pagination';
import {Link} from '@/i18n/navigation';
import {api} from '@/lib/api';
import {formatMoney} from '@/lib/format';
import {formatMoney, formatMoneyCompact} from '@/lib/format';
const ORG_KINDS =['party', 'company', 'public_body', 'ministry', 'municipality', 'media', 'ngo', 'lobby',
const ORG_KINDS = ['party', 'company', 'public_body', 'ministry', 'municipality', 'media', 'ngo', 'lobby',
'parliament', 'government', 'eu_institution', 'other'] as const;
const PAGE = 100;
type Props = {
params: Promise<{locale: string}>;
searchParams: Promise<{q?: string; kind?: string; page?: string}>;
};
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Organizations'});
return {title: t('title')};
}
export default async function OrganizationsPage({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
const sp = await searchParams;
const t = await getTranslations('Organizations');
const loc = await getLocale();
const page = Math.max(1, Number(sp.page) || 1);
const orgs = await api.organizations({q: sp.q, kind: sp.kind, limit: 100, offset: (page - 1) * 100});
const orgs = await api.organizations({q: sp.q, kind: sp.kind, limit: PAGE, offset: (page - 1) * PAGE});
const amount = (value: number | null) => value
? <span className="amount" title={formatMoney(value, locale)}>{formatMoneyCompact(value, locale)}</span>
: <span className="muted">–</span>;
return (
<>
<h1>{t('title')}</h1>
<p className="muted">{t('intro')}</p>
<form className="filters" method="get">
<input type="search" name="q" defaultValue={sp.q} placeholder={t('searchPlaceholder')} />
<label>
{t('kindLabel')}{' '}
<PageHeader title={t('title')} lede={t('intro')} />
<FilterBar id="orgs" active={countActive(sp.q, sp.kind)} resetHref="/organisationen">
<Field label={t('name')} wide><input type="search" name="q" defaultValue={sp.q} placeholder={t('searchPlaceholder')} /></Field>
<Field label={t('kindLabel')}>
<select name="kind" defaultValue={sp.kind ?? ''}>
<option value="">{t('all')}</option>
{ORG_KINDS.map((kind) => <option key={kind} value={kind}>{t(`kind.${kind}`)}</option>)}
</select>
</label>
<button type="submit">OK</button>
</form>
</Field>
</FilterBar>
{orgs.length === 0 ? <p className="muted">{t('none')}</p> : (
<table>
<thead>
<tr><th>{t('name')}</th><th>{t('kindLabel')}</th><th className="num">{t('paid')}</th><th className="num">{t('received')}</th></tr>
</thead>
<tbody>
{orgs.map((org) => (
<tr key={org.slug}>
<td><Link href={`/organisationen/${org.slug}`}>{org.name}</Link></td>
<td className="muted small">{t(`kind.${org.kind}` as 'kind.other')}</td>
<td className="num">{org.paid ? formatMoney(org.paid, loc) : ''}</td>
<td className="num">{org.received ? formatMoney(org.received, loc) : ''}</td>
{orgs.length === 0 ? <EmptyState icon="building">{t('none')}</EmptyState> : (
<div className="table-wrap">
<table className="table-stack">
<thead>
<tr>
<th scope="col">{t('name')}</th><th scope="col">{t('kindLabel')}</th>
<th scope="col" className="num">{t('paid')}</th><th scope="col" className="num">{t('received')}</th>
</tr>
))}
</tbody>
</table>
)}
{orgs.length === 100 && (
<p><Link href={{pathname: '/organisationen', query: {...sp, page: String(page + 1)}}}>→</Link></p>
</thead>
<tbody>
{orgs.map((org) => (
<tr key={org.slug}>
<td data-label={t('name')}><Link href={`/organisationen/${org.slug}`}>{org.name}</Link></td>
<td data-label={t('kindLabel')}><span className="badge">{t(`kind.${org.kind}` as 'kind.other')}</span></td>
<td data-label={t('paid')} className="num">{amount(org.paid)}</td>
<td data-label={t('received')} className="num">{amount(org.received)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination pathname="/organisationen" query={sp} page={page} hasNext={orgs.length === PAGE} />
</>
);
}
+96 -21
View File
@@ -1,48 +1,123 @@
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {PartyTag} from '@/components/badges';
import {StatGrid} from '@/components/charts';
import {ClaimList} from '@/components/ClaimList';
import {Icon, type IconName} from '@/components/Icon';
import {Notice} from '@/components/Notice';
import {SectionHead} from '@/components/PageHeader';
import {SearchForm} from '@/components/SearchForm';
import {VoteList} from '@/components/VoteList';
import {Link} from '@/i18n/navigation';
import {api, getPrefs} from '@/lib/api';
import {formatDate} from '@/lib/format';
import {formatDate, formatNumber} from '@/lib/format';
import {activeFilterCount} from '@/lib/prefs';
const FEED_TYPES = ['vote', 'donation', 'statement', 'media_mention', 'side_job', 'legal_proceeding', 'travel', 'meeting',
'government_decision', 'lobbying'];
const QUICK_LINKS = [
['/geld?kind=donation', 'quickDonations'],
['/abstimmungen?roll_call=1', 'quickRollCalls'],
['/geld?kind=government_ad', 'quickAds'],
['/kalender', 'quickCalendar']
] as const;
const EXPLORE: [string, IconName, string][] = [
['/geld', 'euro', 'Money'],
['/organisationen', 'building', 'Organizations'],
['/social', 'message', 'Social'],
['/kalender', 'calendar', 'Calendar'],
['/ehemalige', 'history', 'Former'],
['/wikipedia', 'pencil', 'Wiki']
];
export default async function HomePage({params}: {params: Promise<{locale: string}>}) {
const {locale} = await params;
setRequestLocale(locale);
const t = await getTranslations('Home');
const [stats, claims, prefs] = await Promise.all([
const [stats, claims, parties, votes, prefs] = await Promise.all([
api.stats(),
api.claims({types: ['vote', 'donation', 'statement', 'media_mention', 'side_job', 'legal_proceeding', 'travel', 'meeting'], limit: 30}),
api.claims({types: FEED_TYPES, limit: 12}),
api.parties(),
api.votes({limit: 5}),
getPrefs()
]);
const filters = activeFilterCount(prefs);
const n = (value: number) => formatNumber(value, locale);
return (
<>
<section className="hero">
<h1>{t('headline')}</h1>
<p>{t('intro')}</p>
<p className="lede">{t('intro')}</p>
<SearchForm />
<p className="muted small">
{t('stats', {persons: stats.persons, active: stats.active_persons, claims: stats.claims, corroborated: stats.corroborated, votes: stats.vote_events, rollCalls: stats.roll_call_votes, money: stats.money_flows})}
{stats.last_update && <> · {t('lastUpdate', {date: formatDate(stats.last_update, locale)})}</>}
<p className="quick-links">
{t('quick')}:
{QUICK_LINKS.map(([href, key]) => <Link key={href} href={href}>{t(key)}</Link>)}
</p>
{(prefs.excludedSources.length > 0 || prefs.topics.length > 0) && (
<p className="notice">
{t('filtersActive', {sources: prefs.excludedSources.length, topics: prefs.topics.length})}
</p>
)}
</section>
<section className="explore">
<h2>{t('explore')}</h2>
<ul className="cards">
{([['/geld', 'exploreMoney'], ['/organisationen', 'exploreOrganizations'], ['/social', 'exploreSocial'],
['/wikipedia', 'exploreWiki'], ['/ehemalige', 'exploreFormer'], ['/kalender', 'exploreCalendar']] as const).map(([href, key]) => (
<li key={href} className="card"><Link href={href} className="card-link">{t(key)}</Link></li>
<StatGrid
items={[
{label: t('statPersons', {active: n(stats.active_persons)}), value: n(stats.persons), href: '/personen'},
{label: t('statClaims', {corroborated: n(stats.corroborated)}), value: n(stats.claims), href: '/suche'},
{label: t('statVotes', {rollCalls: n(stats.roll_call_votes)}), value: n(stats.vote_events), href: '/abstimmungen'},
{label: t('statMoney'), value: n(stats.money_flows), href: '/geld'}
]}
/>
{stats.last_update && <p className="small muted">{t('lastUpdate', {date: formatDate(stats.last_update, locale)})}</p>}
{filters > 0 && (
<Notice tone="info">
{t('filtersActive', {count: filters})} <Link href="/einstellungen">{t('changeFilters')}</Link>
</Notice>
)}
<div className="layout-aside section">
<section aria-labelledby="latest">
<SectionHead id="latest" title={t('latest')} more={{href: '/suche', label: t('searchInTime')}} />
<ClaimList claims={claims} />
</section>
<aside>
<section className="panel" aria-labelledby="parties">
<h2 id="parties">{t('parties')}</h2>
<ul className="link-list">
{parties.filter((p) => p.active_officials > 0).slice(0, 10).map((p) => (
<li key={p.slug} className="cluster">
<PartyTag short={p.short_name ?? p.name} slug={p.slug} color={p.color} />
<span className="muted small">{t('activeOfficials', {count: p.active_officials})}</span>
</li>
))}
</ul>
</section>
<section className="panel" aria-labelledby="votes">
<h2 id="votes">{t('latestVotes')}</h2>
<VoteList votes={votes} compact />
<Link href="/abstimmungen" className="more-link">{t('allVotes')} <span aria-hidden="true">→</span></Link>
</section>
</aside>
</div>
<section className="section" aria-labelledby="explore">
<SectionHead id="explore" title={t('explore')} />
<ul className="grid-cards">
{EXPLORE.map(([href, icon, key]) => (
<li key={href}>
<Link href={href} className="card-link">
<span className="card-link-title"><Icon name={icon} /> {t(`explore${key}Title` as 'exploreMoneyTitle')}</span>
<span className="muted small">{t(`explore${key}` as 'exploreMoney')}</span>
</Link>
</li>
))}
</ul>
</section>
<section>
<h2>{t('latest')}</h2>
<ClaimList claims={claims} />
<section className="section" aria-labelledby="how">
<SectionHead id="how" title={t('howTitle')} more={{href: '/methodik', label: t('howMore')}} />
<ul className="principles">
<li><Icon name="document" /><div><strong>{t('howEvidenceTitle')}</strong><p>{t('howEvidence')}</p></div></li>
<li><Icon name="checks" /><div><strong>{t('howCorroborationTitle')}</strong><p>{t('howCorroboration')}</p></div></li>
<li><Icon name="scale" /><div><strong>{t('howInnocenceTitle')}</strong><p>{t('howInnocence')}</p></div></li>
</ul>
</section>
</>
);
+153 -102
View File
@@ -1,12 +1,19 @@
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 {VoteTable} from '@/components/VoteTable';
import {VoteLegend, VoteList} from '@/components/VoteList';
import {Link} from '@/i18n/navigation';
import {api, NotFoundError, type PartyDetail} from '@/lib/api';
import {formatDate, formatMoney} from '@/lib/format';
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}>};
@@ -34,133 +41,164 @@ export default async function PartyPage({params}: Props) {
const tm = await getTranslations('Money');
const party = await loadParty(slug);
const [votes, claims, posts] = await Promise.all([
api.votes({party: slug, limit: 15}),
api.claims({party: slug, types: ['donation', 'media_mention', 'legal_proceeding', 'money_flow', 'lobbying'], limit: 30}),
api.social({party: slug, limit: 5})
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);
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>
<h1 style={{borderBottomColor: party.color ?? undefined}} className="party-title">
{party.short_name ?? party.name} <span className="muted small">{party.short_name && party.name}</span>
</h1>
<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>
<h2>{t('members')} ({party.members.length})</h2>
<ul className="compact">
{party.members.slice(0, 200).map((m) => (
<li key={m.slug}>
<Link href={`/personen/${m.slug}`}>{m.full_name}</Link>{' '}
<span className="muted small">
{m.role === 'mp' ? m.body : m.role} · {tl(`level.${m.level}` as 'level.other')}
{m.region && <> · {m.region}</>}
</span>
</li>
))}
</ul>
<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>
<h2>{t('money')}</h2>
<section aria-labelledby="money">
<SectionHead id="money" title={t('money')} more={{href: `/geld?org=${slug}`, label: t('allMoney')}} />
{years.length ? (
<table>
<thead>
<tr><th>{t('year')}</th>{kinds.map((kind) => <th key={kind}>{tm(`kind.${kind}` as 'kind.other')}</th>)}</tr>
</thead>
<tbody>
{years.map((year) => (
<tr key={year}>
<td>{year}</td>
{kinds.map((kind) => {
const row = cell(year, kind);
return (
<td key={kind} className="num" title={row ? `${row.count}×` : undefined}>
{row?.total ? formatMoney(row.total, locale) : row ? `${row.count}×` : ''}
</td>
);
})}
</tr>
))}
</tbody>
</table>
) : <p className="muted">–</p>}
<p className="small"><Link href={`/geld?org=${slug}`}>→ {t('allMoney')}</Link></p>
<h2>{t('voteStats')}</h2>
<p>
{party.vote_stats.map((s) => (
<span key={s.position} className={`pos pos-${s.position}`}>{tv(s.position as 'for')}: {s.count}</span>
))}
</p>
</section>
</div>
{(party.finance.length > 0 || party.related.length > 0) && (
<div className="grid-2">
{party.finance.length > 0 && (
<section>
<h2>{t('finance')}</h2>
<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>
{party.finance.map((f) => (
<tr key={String(f.data.year ?? f.valid_from)}>
<td>{String(f.data.year ?? formatDate(f.valid_from, locale))}</td>
<td className="num">
{f.data.total_income ? `${t('income')} ${formatMoney(Number(f.data.total_income), locale)}` : ''}
</td>
<td className="num">
{f.data.total_expenses ? `${t('expenses')} ${formatMoney(Number(f.data.total_expenses), locale)}` : ''}
</td>
{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>
</section>
)}
{party.related.length > 0 && (
<section>
<h2>{t('related')}</h2>
<ul className="compact">
{party.related.map((org) => (
<li key={`${org.claim_type}-${org.slug}`}>
<Link href={`/organisationen/${org.slug}`}>{org.name}</Link>{' '}
<span className="muted small">({t(`relation.${org.claim_type}` as 'relation.affiliation')})</span>
</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>
<h2>{t('formerMembers')}</h2>
<ul className="compact">
{party.former_members.map((m) => (
<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 small">
{m.last_office_end && `${tl('lastOffice', {date: formatDate(m.last_office_end, locale)})}`}
{m.last_mention && ` · ${formatDate(m.last_mention, locale)}`}
<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>
<p><Link href={`/ehemalige?party=${slug}`}>→ {t('formerMembers')}</Link></p>
</section>
{party.dissent.length > 0 && (
<section>
<h2>{t('dissent')}</h2>
<ul className="compact">
<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 small">({d.dissenting_votes})</span>
<span className="muted xsmall">{t('dissentCount', {count: d.dissenting_votes})}</span>
</li>
))}
</ul>
@@ -168,24 +206,37 @@ export default async function PartyPage({params}: Props) {
)}
</div>
<section>
<h2>{t('recentVotes')}</h2>
<VoteTable votes={votes} />
<p><Link href={`/abstimmungen?party=${slug}`}>→ {tv('title')}</Link></p>
<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>
<h2>{t('channels')}</h2>
<section className="section" aria-labelledby="channels">
<SectionHead id="channels" title={t('channels')} more={{href: `/social?party=${slug}`, label: t('allPosts')}} />
<SocialFeed posts={posts} />
<p><Link href={`/social?party=${slug}`}>→ {t('allPosts')}</Link></p>
</section>
)}
<section>
<h2>{t('claims')}</h2>
<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>
);
}
+20 -9
View File
@@ -1,24 +1,35 @@
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {PageHeader} from '@/components/PageHeader';
import {Link} from '@/i18n/navigation';
import {api} from '@/lib/api';
import {partyStyle} from '@/lib/format';
export async function generateMetadata({params}: {params: Promise<{locale: string}>}): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Parties'});
return {title: t('title')};
}
export default async function PartiesPage({params}: {params: Promise<{locale: string}>}) {
const {locale} = await params;
setRequestLocale(locale);
const t = await getTranslations('Parties');
const parties = (await api.parties()).filter((p) => (p.total_people ?? 0) > 0);
const parties = (await api.parties())
.filter((p) => p.total_people > 0)
.sort((a, b) => b.active_officials - a.active_officials || b.total_people - a.total_people);
return (
<>
<h1>{t('title')}</h1>
<ul className="cards">
<PageHeader title={t('title')} lede={t('intro')} />
<ul className="grid-cards">
{parties.map((party) => (
<li key={party.slug} className="card" style={{borderLeftColor: party.color ?? undefined}}>
<Link href={`/parteien/${party.slug}`} className="card-link">
<span>
<strong>{party.short_name ?? party.name}</strong>
<span className="muted small block">{party.name}</span>
<span className="small">{t('activeOfficials', {count: party.active_officials ?? 0})}</span>
<li key={party.slug}>
<Link href={`/parteien/${party.slug}`} className="card-link party-card" style={partyStyle(party.color)}>
<span className="card-link-title">{party.short_name ?? party.name}</span>
{party.short_name && <span className="muted small">{party.name}</span>}
<span className="small">
{t('activeOfficials', {count: party.active_officials})} · {t('tracked', {count: party.total_people})}
</span>
</Link>
</li>
+195 -124
View File
@@ -1,30 +1,50 @@
import type {Metadata} from 'next';
import {notFound} from 'next/navigation';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {Avatar} from '@/components/Avatar';
import {PartyTag, platformName, StatusBadge} from '@/components/badges';
import {StatGrid, type Stat} from '@/components/charts';
import {ClaimList} from '@/components/ClaimList';
import {ExternalLink} from '@/components/ExternalLink';
import {Icon} from '@/components/Icon';
import {MoneyTable} from '@/components/MoneyTable';
import {Notice} from '@/components/Notice';
import {Breadcrumbs, SectionHead} from '@/components/PageHeader';
import {RoleTimeline} from '@/components/RoleTimeline';
import {SocialFeed} from '@/components/SocialFeed';
import {Tabs} from '@/components/Tabs';
import {DateRangeForm, Timeline} from '@/components/Timeline';
import {VoteTable} from '@/components/VoteTable';
import {VoteList} from '@/components/VoteList';
import {Link} from '@/i18n/navigation';
import {api, NotFoundError, type Person} from '@/lib/api';
import {formatDate, formatMoney} from '@/lib/format';
import {api, NotFoundError, type ClaimType, type Person} from '@/lib/api';
import {formatDate, formatMoneyCompact, formatNumber} from '@/lib/format';
import {isCurrent, roleLabel} from '@/lib/roles';
// Claim types per tab; tabs with an empty list render their own component (see below).
// Claim types per tab (also used for the tab counts); tabs with their own component are listed in CUSTOM_TABS.
const TABS = {
overview: ['statement', 'vote', 'donation', 'side_job', 'media_mention', 'meeting', 'travel', 'legal_proceeding',
'career', 'government_decision', 'declaration'],
timeline: [],
votes: [],
votes: ['vote'],
travel: ['travel', 'meeting', 'appearance', 'government_decision'],
statements: ['statement', 'parliamentary_question'],
social: [],
money: ['side_job', 'donation', 'money_flow', 'lobbying', 'declaration', 'ownership'],
media: ['media_mention', 'career', 'wiki_edit'],
legal: ['legal_proceeding']
} as const;
const CUSTOM_TABS: Tab[] = ['overview', 'timeline', 'votes', 'social', 'money'];
} satisfies Record<string, ClaimType[]>;
type Tab = keyof typeof TABS;
const CUSTOM_TABS: Tab[] = ['overview', 'timeline', 'votes', 'social', 'money'];
// "At a glance" counts work, not allegations (docs/design/README.md §6.4).
const GLANCE: [string, ClaimType[], Tab][] = [
['votes', ['vote'], 'votes'],
['statements', ['statement'], 'statements'],
['questions', ['parliamentary_question'], 'statements'],
['appearances', ['appearance', 'meeting', 'travel'], 'travel'],
['sideJobs', ['side_job'], 'money'],
['media', ['media_mention'], 'media']
];
type Props = {
params: Promise<{locale: string; slug: string}>;
@@ -41,9 +61,10 @@ async function loadPerson(slug: string): Promise<Person> {
}
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {slug} = await params;
const {slug, locale} = await params;
const person = await loadPerson(slug);
return {title: person.full_name};
const t = await getTranslations({locale, namespace: 'Person'});
return {title: person.full_name, description: t('metaDescription', {name: person.full_name})};
}
export default async function PersonPage({params, searchParams}: Props) {
@@ -52,119 +73,170 @@ export default async function PersonPage({params, searchParams}: Props) {
const {tab: rawTab, since, until} = await searchParams;
const tab: Tab = rawTab && rawTab in TABS ? (rawTab as Tab) : 'overview';
const t = await getTranslations('Person');
const tl = await getTranslations('Persons');
const tp = await getTranslations('Persons');
const person = await loadPerson(slug);
const today = new Date();
const currentRoles = person.roles.filter((r) => !r.end_date || new Date(r.end_date) >= today);
const pastRoles = person.roles.filter((r) => r.end_date && new Date(r.end_date) < today);
const counts = person.claim_counts ?? {};
const sum = (types: readonly string[]) => types.reduce((acc, type) => acc + (counts[type] ?? 0), 0);
const roles = [...person.roles].sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || (b.start_date ?? '').localeCompare(a.start_date ?? ''));
const offices = roles.filter((r) => isCurrent(r) && r.level !== 'party').slice(0, 4);
const memberships = roles.filter((r) => isCurrent(r) && r.role === 'party_member' && !offices.some((o) => o.party_slug === r.party_slug));
const href = (key: Tab) => (key === 'overview' ? `/personen/${slug}` : `/personen/${slug}?tab=${key}`);
const glance: Stat[] = GLANCE.filter(([, types]) => sum(types) > 0)
.map(([key, types, target]) => ({label: t(`glance.${key}`), value: formatNumber(sum(types), locale), href: href(target)}));
return (
<article className="profile">
<article>
<Breadcrumbs items={[{label: tp('title'), href: '/personen'}, {label: person.full_name}]} />
<header className="profile-head">
{person.photo_url && <img src={person.photo_url} alt="" className="portrait" />}
<Avatar name={person.full_name} src={person.photo_url} size="lg" />
<div>
<h1>{person.full_name}</h1>
<p>
<span className={`status-badge status-${person.status}`} title={t('statusNote')}>
{tl(`statusValue.${person.status}`)}
</span>
{person.status === 'active' && !person.status_verified && (
<span className="muted small"> ({t('unverified')})</span>
)}
<div className="cluster">
<StatusBadge status={person.status} verified={person.status_verified} />
{person.status === 'active' && !person.status_verified && <span className="muted small">{t('unverified')}</span>}
{person.status !== 'active' && person.last_office_end && (
<span className="muted small"> · {tl('lastOffice', {date: formatDate(person.last_office_end, locale)})}</span>
<span className="muted small">{tp('lastOffice', {date: formatDate(person.last_office_end, locale)})}</span>
)}
</p>
{person.birth_date && (
<p className="muted">
* {formatDate(person.birth_date, locale)}
{person.death_date && <> · † {formatDate(person.death_date, locale)}</>}
</div>
<h1>{person.full_name}</h1>
{(offices.length > 0 || memberships.length > 0) && (
<ul className="profile-roles">
{offices.map((r, i) => (
<li key={i}>
<span>{roleLabel(r)}{r.region && ` · ${r.region}`}</span>
{r.party_short && <PartyTag short={r.party_short} slug={r.party_slug} color={r.party_color} />}
</li>
))}
{memberships.map((r, i) => (
<li key={`m${i}`}>
<span className="muted">{t('member')}</span>
<PartyTag short={r.party_short ?? r.body} slug={r.party_slug} color={r.party_color} />
</li>
))}
</ul>
)}
{(person.birth_date || person.death_date) && (
<p className="profile-facts">
{person.birth_date && <span>{t('born', {date: formatDate(person.birth_date, locale)})}</span>}
{person.death_date && <span>{t('died', {date: formatDate(person.death_date, locale)})}</span>}
</p>
)}
<p>
{currentRoles
.filter((r) => r.level !== 'party')
.slice(0, 4)
.map((r, i) => (
<span key={i} className="role-pill">
{r.role === 'mp' ? r.body : r.role}
{r.party_short && <> · {r.party_slug ? <Link href={`/parteien/${r.party_slug}`}>{r.party_short}</Link> : r.party_short}</>}
</span>
))}
</p>
{person.social_accounts.length > 0 && (
<p className="small">
{t('social')}:{' '}
{person.social_accounts.map((a) => (
<a key={a.url} href={a.url} target="_blank" rel="noopener noreferrer nofollow"
className={a.verified ? 'social' : 'social unverified'} title={a.verified ? undefined : t('accountUnverified')}>
{a.platform}{!a.verified && '*'}
<ul className="ext-links" aria-label={t('elsewhere')}>
{person.social_accounts.map((a) => (
<li key={a.url}>
<a href={a.url} target="_blank" rel="noopener noreferrer nofollow" className={a.verified ? 'ext-link' : 'ext-link is-unverified'}
title={a.verified ? undefined : t('accountUnverified')}>
{platformName(a.platform)}{!a.verified && <span aria-label={t('accountUnverified')}>*</span>} <Icon name="external" />
</a>
))}
</p>
)}
</li>
))}
{person.parlament_pad && (
<li><ExternalLink className="ext-link" href={`https://www.parlament.gv.at/person/${person.parlament_pad}`}>{t('parliamentProfile')}</ExternalLink></li>
)}
{person.ep_id && (
<li><ExternalLink className="ext-link" href={`https://www.europarl.europa.eu/meps/${locale}/${person.ep_id}`}>{t('epProfile')}</ExternalLink></li>
)}
{person.wikidata_qid && (
<li><ExternalLink className="ext-link" href={`https://www.wikidata.org/wiki/${person.wikidata_qid}`}>Wikidata</ExternalLink></li>
)}
</ul>
</div>
</header>
<nav className="tabs">
{(Object.keys(TABS) as Tab[]).map((key) => (
<Link key={key} href={`/personen/${slug}?tab=${key}`} className={key === tab ? 'active' : undefined}>
{t(`tabs.${key}`)}
</Link>
))}
</nav>
<Tabs
label={t('sections')}
locale={locale}
current={tab}
items={(Object.keys(TABS) as Tab[]).map((key) => ({
key,
label: t(`tabs.${key}`),
href: href(key),
count: key === 'overview' || key === 'social' ? undefined : key === 'timeline' ? sum(Object.keys(counts)) : sum(TABS[key])
}))}
/>
{tab === 'overview' && (
<section>
<h2>{t('roles')}</h2>
<ul className="roles">
{[...currentRoles, ...pastRoles].map((role, i) => (
<li key={i}>
<strong>{role.role === 'mp' ? role.body : role.role === 'party_member' ? role.party_short ?? role.body : role.role}</strong>
{role.party_short && role.role !== 'party_member' && <> · {role.party_short}</>}
{role.region && <> · {role.region}</>}
<span className="muted small">
{' '}({tl(`level.${role.level}` as 'level.other')},{' '}
{role.end_date
? t('period', {from: formatDate(role.start_date, locale), to: formatDate(role.end_date, locale)})
: t('since', {date: formatDate(role.start_date, locale) || '?'})}
)
</span>
{role.claim_status === 'corroborated' && <span className="status-corroborated small"> ✓ {t('roleCorroborated')}</span>}
{(role.evidence ?? []).map((ev) => (
<a key={ev.url} href={ev.url} className="cite" target="_blank" rel="noopener noreferrer">[{ev.source}]</a>
<div className="layout-aside">
<div>
{person.roles.length > 1 && (
<section aria-labelledby="career">
<SectionHead id="career" title={t('career')} />
<RoleTimeline roles={person.roles} />
</section>
)}
<section aria-labelledby="latest">
<SectionHead id="latest" title={t('latest')} more={{href: href('timeline'), label: t('allClaims')}} />
<ClaimList claims={await api.claims({person: slug, types: [...TABS.overview], limit: 15})} showSubject={false} />
</section>
</div>
<aside>
{glance.length > 0 && (
<section className="panel" aria-labelledby="glance">
<h2 id="glance">{t('glanceTitle')}</h2>
<StatGrid items={glance} compact />
</section>
)}
<section className="panel" aria-labelledby="roles">
<h2 id="roles">{t('roles')}</h2>
<ul className="link-list">
{roles.map((role, i) => (
<li key={i}>
<strong>{roleLabel(role)}</strong>
{role.party_short && role.role !== 'party_member' && <> · {role.party_short}</>}
{role.region && <> · {role.region}</>}
<span className="block muted xsmall">
{tp(`level.${role.level}` as 'level.other')}
{role.end_date
? <> · {t('period', {from: formatDate(role.start_date, locale) || '?', to: formatDate(role.end_date, locale)})}</>
: role.start_date && <> · {t('since', {date: formatDate(role.start_date, locale)})}</>}
{role.claim_status === 'corroborated' && <> · <Icon name="checks" /> {t('roleCorroborated')}</>}
</span>
{(role.evidence ?? []).length > 0 && (
<span className="cite-list">
{(role.evidence ?? []).map((ev) => (
<a key={ev.url} href={ev.url} className="cite" target="_blank" rel="noopener noreferrer">{ev.source}</a>
))}
</span>
)}
</li>
))}
</li>
))}
</ul>
<ClaimList claims={await api.claims({person: slug, types: [...TABS.overview], limit: 25})} showSubject={false} />
</section>
</ul>
</section>
</aside>
</div>
)}
{tab === 'timeline' && (
<section>
<Timeline buckets={await api.timeline({person: slug})} basePath={`/personen/${slug}`} query={{tab: 'timeline'}} />
<DateRangeForm since={since} until={until} hidden={{tab: 'timeline'}} />
<Timeline buckets={await api.timeline({person: slug})} basePath={`/personen/${slug}`} query={{tab: 'timeline'}} since={since} />
<DateRangeForm since={since} until={until} hidden={{tab: 'timeline'}} resetHref={href('timeline')} />
<ClaimList claims={await api.claims({person: slug, since, until, limit: 200})} showSubject={false} />
</section>
)}
{tab === 'votes' && <PersonVotes slug={slug} />}
{tab === 'votes' && (
<section>
<Notice tone="info">{t('factionVoteNote')}</Notice>
<VoteList votes={await api.personVotes(slug)} />
</section>
)}
{tab === 'social' && <SocialFeed posts={await api.social({person: slug, limit: 100})} showAuthor={false} />}
{tab === 'money' && (
<>
<PersonNetwork slug={slug} locale={locale} />
<DateRangeForm since={since} until={until} hidden={{tab}} />
<SectionHead title={t('moneyClaims')} />
<DateRangeForm since={since} until={until} hidden={{tab}} resetHref={href(tab)} />
<ClaimList claims={await api.claims({person: slug, types: [...TABS.money], since, until, limit: 100})} showSubject={false} />
</>
)}
{!CUSTOM_TABS.includes(tab) && (
<>
<DateRangeForm since={since} until={until} hidden={{tab}} />
{tab === 'legal' && <Notice tone="legal">{t('legalNote')}</Notice>}
{tab === 'travel' && <Notice tone="note">{t('datePrecision')}</Notice>}
<DateRangeForm since={since} until={until} hidden={{tab}} resetHref={href(tab)} />
<ClaimList claims={await api.claims({person: slug, types: [...TABS[tab]], since, until, limit: 100})} showSubject={false} />
</>
)}
@@ -175,53 +247,52 @@ export default async function PersonPage({params, searchParams}: Props) {
async function PersonNetwork({slug, locale}: {slug: string; locale: string}) {
const t = await getTranslations('Network');
const tm = await getTranslations('Money');
const to = await getTranslations('Organizations');
const [network, flows] = await Promise.all([api.personNetwork(slug), api.money({person: slug, limit: 30})]);
if (!network.organizations.length) return null;
return (
<section>
<h2>{t('title')}</h2>
<p className="notice small">{t('note')}</p>
<table>
<tbody>
{network.organizations.map((org) => (
<tr key={org.id}>
<td><Link href={`/organisationen/${org.slug}`}>{org.name}</Link></td>
<td className="muted small">{org.via.map((v) => t(`via.${v}` as 'via.side_job')).join(', ')}</td>
<td className="small">
{Object.entries(org.money ?? {}).map(([key, value]) => {
// key = '<money kind>_paid' | '<money kind>_received', value = {total, count}
const split = key.lastIndexOf('_');
const {total, count} = value as {total: number | null; count: number};
return (
<span key={key} className="chip">
{tm(`kind.${key.slice(0, split)}` as 'kind.other')} ({t(key.slice(split + 1) as 'paid')}):{' '}
{total ? formatMoney(total, locale) : `${count}×`}
</span>
);
})}
</td>
</tr>
))}
</tbody>
</table>
<section aria-labelledby="network">
<SectionHead id="network" title={t('title')} />
<Notice tone="note">{t('note')}</Notice>
<div className="table-wrap">
<table className="table-stack">
<thead>
<tr><th scope="col">{t('organization')}</th><th scope="col">{t('connection')}</th><th scope="col">{t('money')}</th></tr>
</thead>
<tbody>
{network.organizations.map((org) => (
<tr key={org.id}>
<td data-label={t('organization')}>
<Link href={`/organisationen/${org.slug}`}>{org.name}</Link>
<span className="block muted xsmall">{to(`kind.${org.kind}` as 'kind.other')}</span>
</td>
<td data-label={t('connection')}>
<span className="cluster">{org.via.map((v) => <span key={v} className="badge">{t(`via.${v}` as 'via.side_job')}</span>)}</span>
</td>
<td data-label={t('money')} className="small">
{Object.entries(org.money ?? {}).map(([key, value]) => {
// key = '<money kind>_paid' | '<money kind>_received', value = {total, count}
const split = key.lastIndexOf('_');
const {total, count} = value as {total: number | null; count: number};
return (
<span key={key} className="block">
{tm(`kind.${key.slice(0, split)}` as 'kind.other')} ({t(key.slice(split + 1) as 'paid')}):{' '}
<span className="amount">{total ? formatMoneyCompact(total, locale) : `${count}×`}</span>
</span>
);
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
{flows.length > 0 && (
<>
<h3>{t('flows')}</h3>
<SectionHead title={t('flows')} more={{href: `/geld?person=${slug}`, label: t('allFlows')}} />
<MoneyTable flows={flows} />
<p><Link href={`/geld?person=${slug}`}>→ {t('allFlows')}</Link></p>
</>
)}
</section>
);
}
async function PersonVotes({slug}: {slug: string}) {
const t = await getTranslations('Person');
const votes = await api.personVotes(slug);
return (
<section>
<p className="notice">{t('factionVoteNote')}</p>
{votes.length ? <VoteTable votes={votes} /> : <p className="muted">{t('noVotes')}</p>}
</section>
);
}
+35 -50
View File
@@ -1,15 +1,27 @@
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import {countActive, Field, FilterBar} from '@/components/FilterBar';
import {EmptyState} from '@/components/Notice';
import {PageHeader} from '@/components/PageHeader';
import {Pagination} from '@/components/Pagination';
import {PersonCard} from '@/components/PersonCard';
import {api} from '@/lib/api';
const LEVELS = ['eu', 'federal', 'state', 'municipal', 'party'] as const;
const STATUSES = ['active', 'former', 'party_member', 'deceased', 'all'] as const;
const PAGE = 60;
type Props = {
params: Promise<{locale: string}>;
searchParams: Promise<{q?: string; party?: string; level?: string; status?: string; page?: string}>;
};
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Persons'});
return {title: t('title')};
}
export default async function PersonsPage({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
@@ -18,72 +30,45 @@ export default async function PersonsPage({params, searchParams}: Props) {
const page = Math.max(1, Number(sp.page) || 1);
const status = STATUSES.includes(sp.status as (typeof STATUSES)[number]) ? sp.status! : 'active';
const [persons, parties] = await Promise.all([
api.persons({q: sp.q, party: sp.party, level: sp.level, status: status, limit: 60, offset: (page - 1) * 60}),
api.persons({q: sp.q, party: sp.party, level: sp.level, status, limit: PAGE, offset: (page - 1) * PAGE}),
api.parties()
]);
return (
<>
<h1>{t('title')}</h1>
<form className="filters" method="get">
<input type="search" name="q" defaultValue={sp.q} placeholder="Name …" />
<label>
{t('filterParty')}{' '}
<PageHeader title={t('title')} lede={t('intro')} />
<FilterBar id="persons" active={countActive(sp.q, sp.party, sp.level, sp.status && sp.status !== 'active' ? sp.status : '')}
resetHref="/personen">
<Field label={t('name')} wide><input type="search" name="q" defaultValue={sp.q} placeholder={t('namePlaceholder')} /></Field>
<Field label={t('filterParty')}>
<select name="party" defaultValue={sp.party ?? ''}>
<option value="">{t('all')}</option>
{parties.filter((p) => (p.active_officials ?? 0) > 0 || p.slug === sp.party).map((p) => (
{parties.filter((p) => p.active_officials > 0 || p.slug === sp.party).map((p) => (
<option key={p.slug} value={p.slug}>{p.short_name ?? p.name}</option>
))}
</select>
</label>
<label>
{t('filterLevel')}{' '}
</Field>
<Field label={t('filterLevel')}>
<select name="level" defaultValue={sp.level ?? ''}>
<option value="">{t('all')}</option>
{LEVELS.map((level) => (
<option key={level} value={level}>{t(`level.${level}`)}</option>
))}
{LEVELS.map((level) => <option key={level} value={level}>{t(`level.${level}`)}</option>)}
</select>
</label>
<label>
{t('filterStatus')}{' '}
</Field>
<Field label={t('filterStatus')}>
<select name="status" defaultValue={status}>
{STATUSES.map((s) => (
<option key={s} value={s}>{t(`statusValue.${s}`)}</option>
))}
{STATUSES.map((s) => <option key={s} value={s}>{t(`statusValue.${s}`)}</option>)}
</select>
</label>
<button type="submit">OK</button>
</form>
</Field>
</FilterBar>
{persons.length === 0 && <p className="muted">{t('none')}</p>}
<ul className="cards">
{persons.map((person) => (
<li key={person.slug} className="card">
<Link href={`/personen/${person.slug}`} className="card-link">
{person.photo_url ? <img src={person.photo_url} alt="" className="avatar" loading="lazy" /> : <span className="avatar" />}
<span>
<strong>{person.full_name}</strong>
<span className="muted small block">
{person.status === 'active'
? (person.current_roles ?? []).slice(0, 2).join(' · ')
: <>{t(`statusValue.${person.status}`)}{person.last_office_end && ` · ${t('lastOffice', {date: person.last_office_end.slice(0, 4)})}`}</>}
</span>
{(person.parties ?? []).map((party) => (
<span key={party.slug} className="party-pill" style={{borderColor: party.color ?? undefined}}>
{party.short_name ?? party.slug}
</span>
))}
</span>
</Link>
</li>
))}
</ul>
{persons.length === 60 && (
<p>
<Link href={{pathname: '/personen', query: {...sp, page: String(page + 1)}}}>→</Link>
</p>
{persons.length === 0 ? (
<EmptyState icon="person">{t('none')}</EmptyState>
) : (
<ul className="grid-cards">
{persons.map((person) => <li key={person.slug}><PersonCard person={person} /></li>)}
</ul>
)}
<Pagination pathname="/personen" query={sp} page={page} hasNext={persons.length === PAGE} />
</>
);
}
+31 -18
View File
@@ -1,6 +1,11 @@
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {platformName} from '@/components/badges';
import {countActive, Field, FilterBar} from '@/components/FilterBar';
import {Notice} from '@/components/Notice';
import {PageHeader} from '@/components/PageHeader';
import {Pagination} from '@/components/Pagination';
import {SocialFeed} from '@/components/SocialFeed';
import {Link} from '@/i18n/navigation';
import {api} from '@/lib/api';
const PLATFORMS = ['bluesky', 'mastodon', 'telegram', 'youtube', 'x'] as const;
@@ -11,6 +16,12 @@ type Props = {
searchParams: Promise<{platform?: string; party?: string; page?: string}>;
};
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Social'});
return {title: t('title')};
}
export default async function SocialPage({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
@@ -24,24 +35,26 @@ export default async function SocialPage({params, searchParams}: Props) {
return (
<>
<h1>{t('title')}</h1>
<p className="muted">{t('intro')}</p>
<form className="filters" method="get">
<select name="platform" defaultValue={sp.platform ?? ''} aria-label={t('platform')}>
<option value="">{t('allPlatforms')}</option>
{PLATFORMS.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
<select name="party" defaultValue={sp.party ?? ''} aria-label={t('party')}>
<option value="">{t('allParties')}</option>
{parties.map((p) => <option key={p.slug} value={p.slug}>{p.short_name ?? p.name}</option>)}
</select>
<button type="submit">OK</button>
</form>
<PageHeader title={t('title')} lede={t('intro')} />
<FilterBar id="social" active={countActive(sp.platform, sp.party)} resetHref="/social">
<Field label={t('platform')}>
<select name="platform" defaultValue={sp.platform ?? ''}>
<option value="">{t('allPlatforms')}</option>
{PLATFORMS.map((p) => <option key={p} value={p}>{platformName(p)}</option>)}
</select>
</Field>
<Field label={t('party')}>
<select name="party" defaultValue={sp.party ?? ''}>
<option value="">{t('allParties')}</option>
{parties.filter((p) => p.active_officials > 0 || p.slug === sp.party).map((p) => (
<option key={p.slug} value={p.slug}>{p.short_name ?? p.name}</option>
))}
</select>
</Field>
</FilterBar>
<SocialFeed posts={posts} />
{posts.length === PAGE && (
<p><Link href={{pathname: '/social', query: {...sp, page: String(page + 1)}}}>→</Link></p>
)}
<p className="small muted">{t('policy')}</p>
<Pagination pathname="/social" query={sp} page={page} hasNext={posts.length === PAGE} />
<Notice tone="note">{t('policy')}</Notice>
</>
);
}
+69 -30
View File
@@ -1,13 +1,35 @@
import type {Metadata} from 'next';
import {Fragment} from 'react';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {TypeLabel} from '@/components/badges';
import {ClaimList} from '@/components/ClaimList';
import {ExternalLink} from '@/components/ExternalLink';
import {EmptyState, Notice} from '@/components/Notice';
import {PageHeader} from '@/components/PageHeader';
import {SearchForm} from '@/components/SearchForm';
import {Link} from '@/i18n/navigation';
import {api} from '@/lib/api';
import {api, type ClaimType} from '@/lib/api';
import {formatDate} from '@/lib/format';
type Props = {params: Promise<{locale: string}>; searchParams: Promise<{q?: string; since?: string; until?: string}>};
const DATE = /^\d{4}-\d{2}-\d{2}$/;
/** Marks the query words in a text (server-side, plain string matching). */
function highlight(text: string, q: string) {
const words = q.split(/\s+/).filter((w) => w.length >= 3).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
if (!words.length) return text;
const parts = text.split(new RegExp(`(${words.join('|')})`, 'gi'));
return parts.map((part, i) => (i % 2 ? <mark key={i}>{part}</mark> : <Fragment key={i}>{part}</Fragment>));
}
export async function generateMetadata({params, searchParams}: Props): Promise<Metadata> {
const {locale} = await params;
const {q} = await searchParams;
const t = await getTranslations({locale, namespace: 'Search'});
return {title: q ? t('results', {q}) : t('title'), robots: {index: false}};
}
export default async function SearchPage({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
@@ -20,40 +42,57 @@ export default async function SearchPage({params, searchParams}: Props) {
const hits = q && q.length >= 2 ? await api.search(q, since, until) : [];
// Without a query, a date range lists everything that happened in that period ("search in time").
const inRange = !q && (since || until) ? await api.claims({since, until, limit: 200}) : [];
const range = [since && formatDate(since, locale), until && formatDate(until, locale)].filter(Boolean).join(' – ');
return (
<>
<SearchForm defaultValue={q} since={since} until={until} withDates />
{q && <h1>{t('results', {q})}</h1>}
{q && hits.length === 0 && <p className="muted">{t('none')}</p>}
<ul className="claims">
{hits.map((hit) => (
<li key={hit.id} className="claim">
{hit.person_slug && <Link href={`/personen/${hit.person_slug}`}>{hit.person_name}</Link>}
{hit.summary_de ? (
<p className="claim-summary">{hit.summary_de}</p>
) : (
<p className="claim-summary">
{hit.document_url && (
<a href={hit.document_url} target="_blank" rel="noopener noreferrer nofollow">
<strong>{hit.document_title ?? hit.document_url}</strong>
</a>
<PageHeader
title={q ? t('results', {q}) : since || until ? t('inTime', {range}) : t('title')}
lede={!q && !since && !until ? t('intro') : undefined}
/>
<SearchForm defaultValue={q} since={since} until={until} withDates autoFocus={!q && !since && !until} />
{q && hits.length === 0 && <EmptyState icon="search">{t('none')}</EmptyState>}
{hits.length > 0 && (
<ul className="claims">
{hits.map((hit) => (
<li key={hit.id}>
<article className="claim">
<div className="claim-head">
{hit.claim_type && <TypeLabel type={hit.claim_type as ClaimType} />}
{(hit.occurred_at ?? hit.document_date) && <time>{formatDate(hit.occurred_at ?? hit.document_date, locale)}</time>}
{hit.person_slug && <Link href={`/personen/${hit.person_slug}`}>{hit.person_name}</Link>}
</div>
{hit.summary_de ? (
<p className="claim-summary">{highlight(hit.summary_de, q ?? '')}</p>
) : (
<>
{hit.document_url && (
<p className="claim-summary">
<ExternalLink href={hit.document_url}>{hit.document_title ?? hit.document_url}</ExternalLink>
</p>
)}
<p className="hit-text">{highlight(`${hit.text.slice(0, 280)}…`, q ?? '')}</p>
</>
)}
<span className="block small muted">{hit.text.slice(0, 280)}…</span>
</p>
)}
<ol className="small">
{(hit.evidence ?? []).map((ev) => (
<li key={ev.url}>
<a href={ev.url} target="_blank" rel="noopener noreferrer nofollow">{ev.title || ev.url}</a>{' '}
<span className="muted">({ev.source})</span>
</li>
))}
</ol>
</li>
))}
</ul>
{(hit.evidence ?? []).length > 0 && (
<p className="cite-list">
{(hit.evidence ?? []).map((ev) => (
<a key={ev.url} href={ev.url} className="cite" target="_blank" rel="noopener noreferrer nofollow" title={ev.title ?? undefined}>
{ev.source}
</a>
))}
{hit.claim_id && <Link href={`/angaben/${hit.claim_id}`} className="small">{t('details')}</Link>}
</p>
)}
</article>
</li>
))}
</ul>
)}
{!q && (since || until) && <ClaimList claims={inRange} />}
{!q && !since && !until && <Notice tone="info">{t('tips')}</Notice>}
</>
);
}
+47 -28
View File
@@ -1,4 +1,9 @@
import type {Metadata} from 'next';
import {getTranslations, setRequestLocale} from 'next-intl/server';
import {ExternalLink} from '@/components/ExternalLink';
import {EmptyState, Notice} from '@/components/Notice';
import {PageHeader} from '@/components/PageHeader';
import {Pagination} from '@/components/Pagination';
import {Link} from '@/i18n/navigation';
import {api} from '@/lib/api';
import {formatDate} from '@/lib/format';
@@ -7,6 +12,12 @@ const PAGE = 100;
type Props = {params: Promise<{locale: string}>; searchParams: Promise<{page?: string}>};
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'WikiEdits'});
return {title: t('title')};
}
export default async function WikiEditsPage({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
@@ -17,36 +28,44 @@ export default async function WikiEditsPage({params, searchParams}: Props) {
return (
<>
<h1>{t('title')}</h1>
<p className="muted">{t('intro')}</p>
{edits.length === 0 ? <p className="muted">{t('none')}</p> : (
<table>
<thead>
<tr><th>{t('date')}</th><th>{t('network')}</th><th>{t('article')}</th><th>{t('change')}</th></tr>
</thead>
<tbody>
{edits.map((edit) => (
<tr key={edit.id}>
<td className="nowrap">{formatDate(edit.occurred_at, locale)}</td>
<td><Link href={`/organisationen/${edit.org_slug}`}>{edit.org_name}</Link></td>
<td>
{edit.url ? <a href={edit.url} target="_blank" rel="noopener noreferrer">{String(edit.data.title ?? '')}</a>
: String(edit.data.title ?? '')}
{edit.person_slug && <> · <Link href={`/personen/${edit.person_slug}`}>{edit.person_name}</Link></>}
</td>
<td className="small">
{edit.data.sizediff !== undefined && <span className="num">{Number(edit.data.sizediff) > 0 ? '+' : ''}{String(edit.data.sizediff)}</span>}
{edit.data.comment ? <span className="muted"> {String(edit.data.comment)}</span> : null}
</td>
<PageHeader title={t('title')} lede={t('intro')} />
{/* The framing comes before the data: a network is not a person. */}
<Notice tone="caution">{t('note')}</Notice>
{edits.length === 0 ? <EmptyState icon="pencil">{t('none')}</EmptyState> : (
<div className="table-wrap">
<table className="table-stack">
<thead>
<tr>
<th scope="col">{t('date')}</th><th scope="col">{t('network')}</th>
<th scope="col">{t('article')}</th><th scope="col">{t('change')}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{edits.map((edit) => {
const size = edit.data.sizediff !== undefined && edit.data.sizediff !== null ? Number(edit.data.sizediff) : null;
return (
<tr key={edit.id}>
<td data-label={t('date')} className="nowrap">{formatDate(edit.occurred_at, locale)}</td>
<td data-label={t('network')}><Link href={`/organisationen/${edit.org_slug}`}>{edit.org_name}</Link></td>
<td data-label={t('article')}>
{edit.url ? <ExternalLink href={edit.url}>{String(edit.data.title ?? '')}</ExternalLink> : String(edit.data.title ?? '')}
{edit.person_slug && <> · <Link href={`/personen/${edit.person_slug}`}>{edit.person_name}</Link></>}
</td>
<td data-label={t('change')} className="small">
{size !== null && (
<span className={`mono ${size >= 0 ? '' : 'muted'}`}>{size > 0 ? '+' : ''}{size}</span>
)}
{edit.data.comment ? <span className="block muted">{String(edit.data.comment)}</span> : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{edits.length === PAGE && (
<p><Link href={{pathname: '/wikipedia', query: {page: String(page + 1)}}}>→</Link></p>
)}
<p className="small muted">{t('note')}</p>
<Pagination pathname="/wikipedia" query={sp} page={page} hasNext={edits.length === PAGE} />
<p className="small muted">{t('ranges')}</p>
</>
);
}
-159
View File
@@ -1,159 +0,0 @@
:root {
--bg: #fbfaf8;
--fg: #1d1d1f;
--muted: #6b6b70;
--line: #e4e2dd;
--card: #ffffff;
--accent: #c8102e; /* Austrian red */
--ok: #2e7d32;
--bad: #b71c1c;
--warn: #8a6d00;
--warn-bg: #fff6d6;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #151517;
--fg: #ececee;
--muted: #9a9aa2;
--line: #2c2c31;
--card: #1d1d21;
--warn-bg: #3a3112;
--warn: #f0d070;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); line-height: 1.5; }
a { color: inherit; text-decoration-color: var(--accent); text-underline-offset: 2px; }
img { max-width: 100%; }
main { max-width: 1100px; margin: 0 auto; padding: 1rem; }
h1 { font-size: 1.8rem; line-height: 1.2; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--line); vertical-align: top; }
input, select, button { font: inherit; padding: 0.4rem 0.6rem; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); }
button { background: var(--accent); color: #fff; border: none; cursor: pointer; }
button.secondary { background: transparent; color: var(--fg); border: 1px solid var(--line); }
.site-header { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 3px solid var(--accent); background: var(--card); }
.site-header nav { display: flex; flex-wrap: wrap; gap: 1rem; }
.site-header nav a { text-decoration: none; }
.brand { font-weight: 800; font-size: 1.2rem; text-decoration: none; }
.hero { padding: 1rem 0 0.5rem; }
.search { display: flex; gap: 0.5rem; margin: 1rem 0; }
.search input { flex: 1; }
.filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-bottom: 1rem; }
.muted { color: var(--muted); }
.small { font-size: 0.85rem; }
.block { display: block; }
.nowrap { white-space: nowrap; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.notice { background: var(--warn-bg); color: var(--warn); padding: 0.5rem 0.75rem; border-radius: 6px; font-size: 0.9rem; }
.cards { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
.card { background: var(--card); border: 1px solid var(--line); border-left: 4px solid var(--line); border-radius: 8px; }
.card-link { display: flex; gap: 0.75rem; padding: 0.75rem; text-decoration: none; align-items: center; }
.avatar { width: 48px; height: 60px; object-fit: cover; border-radius: 4px; background: var(--line); flex-shrink: 0; }
.party-pill, .role-pill, .chip { display: inline-block; font-size: 0.8rem; padding: 0 0.45rem; margin: 0.15rem 0.25rem 0 0; border: 1px solid var(--line); border-radius: 999px; }
.party-pill { border-width: 2px; }
.claims { list-style: none; padding: 0; }
.claim { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 0.75rem; margin-bottom: 0.6rem; }
.claim-head { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; font-size: 0.85rem; color: var(--muted); }
.claim-summary { margin: 0.35rem 0; }
.claim-disputed { border-color: var(--warn); }
.tag { font-weight: 600; color: var(--fg); }
.status { margin-left: auto; }
.status-corroborated { color: var(--ok); }
.status-disputed { color: var(--warn); }
.evidence summary { cursor: pointer; font-size: 0.85rem; color: var(--muted); }
.evidence ol { font-size: 0.9rem; padding-left: 1.25rem; }
.evidence blockquote { margin: 0.25rem 0 0.5rem; padding-left: 0.75rem; border-left: 3px solid var(--line); color: var(--muted); }
.source { font-weight: 600; }
.cite { margin-left: 0.3rem; font-size: 0.8rem; color: var(--muted); }
.profile-head { display: flex; gap: 1.25rem; align-items: flex-start; }
.portrait { width: 140px; border-radius: 8px; }
.social { margin-right: 0.5rem; }
.tabs { display: flex; flex-wrap: wrap; gap: 0.25rem; border-bottom: 1px solid var(--line); margin: 1rem 0; }
.tabs a { padding: 0.5rem 0.75rem; text-decoration: none; border-bottom: 3px solid transparent; }
.tabs a.active { border-bottom-color: var(--accent); font-weight: 600; }
.roles { padding-left: 1.2rem; }
.compact { padding-left: 1.2rem; max-height: 32rem; overflow: auto; }
.votes td { font-size: 0.92rem; }
.pos { display: inline-block; padding: 0 0.4rem; margin: 0 0.25rem 0.25rem 0; border-radius: 4px; font-size: 0.82rem; }
.pos-for { background: #e3f3e4; color: #1b5e20; }
.pos-against { background: #fbe4e4; color: #8e1b1b; }
.pos-abstain, .pos-absent { background: var(--line); }
.result-accepted { color: var(--ok); }
.result-rejected { color: var(--bad); }
.party-title { border-bottom: 4px solid var(--line); padding-bottom: 0.3rem; }
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1.5rem; }
.settings fieldset { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 1rem; }
.check { display: block; padding: 0.15rem 0; }
.topic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.source-group h3 { font-size: 1rem; margin: 0.75rem 0 0.25rem; }
.prose { max-width: 60rem; }
@media (max-width: 640px) {
.profile-head { flex-direction: column; }
.status { margin-left: 0; }
}
/* --- status, legal, votes --- */
.status-badge { display: inline-block; padding: 0.1rem 0.6rem; border-radius: 999px; font-size: 0.85rem; font-weight: 600; border: 1px solid var(--line); }
.status-badge.status-active { background: #e3f3e4; color: #1b5e20; border-color: #b7dfb9; }
.status-badge.status-former, .status-badge.status-party_member { background: var(--line); color: var(--fg); }
.status-badge.status-deceased { background: transparent; color: var(--muted); }
.innocence { font-weight: 600; }
.claim-legal { border-left: 4px solid var(--warn); }
.legal-stage { margin: 0.3rem 0 0; font-size: 0.9rem; }
.dissent { display: inline-block; margin-left: 0.4rem; padding: 0 0.4rem; border-radius: 4px; font-size: 0.78rem; background: var(--warn-bg); color: var(--warn); }
/* --- timeline --- */
.timeline { margin: 0 0 1rem; }
.timeline-bars { display: flex; align-items: flex-end; gap: 2px; height: 90px; border-bottom: 1px solid var(--line); }
.timeline-bar { flex: 1; height: 100%; display: flex; align-items: flex-end; min-width: 3px; }
.timeline-bar span { display: block; width: 100%; background: var(--accent); opacity: 0.75; border-radius: 2px 2px 0 0; }
.timeline-bar:hover span { opacity: 1; }
.timeline-axis { display: flex; justify-content: space-between; }
/* --- calendar --- */
.calendar { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }
.calendar-head { font-weight: 600; font-size: 0.85rem; text-align: center; color: var(--muted); }
.calendar-cell { min-height: 6.5rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem; font-size: 0.8rem; overflow: hidden; }
.calendar-cell.empty { background: transparent; border: none; }
.calendar-cell.has-events { border-color: var(--accent); }
.calendar-day { display: flex; justify-content: space-between; gap: 0.25rem; }
.calendar-cell ul { list-style: none; padding: 0; margin: 0.25rem 0 0; }
.calendar-cell li { margin-bottom: 0.25rem; line-height: 1.25; }
.ev-kind { font-weight: 600; color: var(--muted); }
.ev-foreign_trip .ev-kind, .ev-meeting .ev-kind { color: var(--accent); }
@media (max-width: 760px) {
.calendar { grid-template-columns: 1fr; }
.calendar-head, .calendar-cell.empty { display: none; }
.calendar-cell { min-height: 0; }
}
/* v2: claim details, money, social, organisations */
.claim-detail { margin: 0.2rem 0; font-size: 0.92rem; }
.money-line strong { font-variant-numeric: tabular-nums; }
.compact-table td { padding: 0.1rem 0.5rem; font-size: 0.85rem; }
table.money td { vertical-align: top; }
.platform { text-transform: capitalize; }
.platform-bluesky { border-color: #1185fe; }
.platform-mastodon { border-color: #6364ff; }
.platform-telegram { border-color: #229ed9; }
.platform-youtube { border-color: #ff0000; }
.post-text { white-space: pre-line; }
.social.unverified { color: var(--muted); }
.say-vote { margin-top: 0.2rem; }
.identifier { margin-right: 1rem; }
.explore .card-link { display: block; }
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#4338ca"/>
<path d="M12 7.5H8.5v17H12M20 7.5h3.5v17H20" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16" cy="16" r="3" fill="#fde68a"/>
</svg>

After

Width:  |  Height:  |  Size: 313 B

+13
View File
@@ -0,0 +1,13 @@
import {initials} from '@/lib/format';
/**
* Portrait or initials. Portraits come from Wikimedia/parliament: loaded lazily and without a referrer.
* Decorative (alt=""), because the name is always printed next to it.
*/
export function Avatar({name, src, size = 'md'}: {name: string; src?: string | null; size?: 'md' | 'lg'}) {
const className = size === 'lg' ? 'avatar avatar--lg' : 'avatar';
if (src) {
return <img src={src} alt="" className={className} loading="lazy" decoding="async" referrerPolicy="no-referrer" />;
}
return <span className={className} aria-hidden="true">{initials(name)}</span>;
}
+36 -23
View File
@@ -3,7 +3,9 @@
import {Fragment, type ReactNode} from 'react';
import {Link} from '@/i18n/navigation';
import type {Claim, ClaimType} from '@/lib/api';
import {formatDate, formatMoney} from '@/lib/format';
import {formatDate, formatMoney, formatPeriod} from '@/lib/format';
import {PlatformBadge} from './badges';
import {Icon} from './Icon';
type Ctx = {locale: string; t: (key: string, values?: Record<string, string | number>) => string};
type Renderer = (claim: Claim, ctx: Ctx) => ReactNode;
@@ -17,19 +19,27 @@ function str(value: unknown): string | null {
return value === null || value === undefined || value === '' ? null : String(value);
}
/** "a · b · c" from the non-empty parts. */
function joined(parts: ReactNode[]): ReactNode {
const items = parts.filter((p) => p !== null && p !== undefined && p !== false && p !== '');
return items.map((part, i) => <Fragment key={i}>{i > 0 && ' · '}{part}</Fragment>);
}
const money: Renderer = (claim, {locale, t}) => {
const m = claim.money;
if (!m) return null;
const amount = m.amount !== null && m.amount !== undefined ? formatMoney(m.amount, locale) : m.amount_category;
return (
<p className="claim-detail money-line">
{amount && <strong>{amount}</strong>}
{(m.payer_name || m.payee_name) && (
<> · {m.payer_name ?? '?'} → {m.payee_name ?? '?'}</>
)}
{m.paid_on ? <> · {formatDate(m.paid_on, locale)}</> : m.period ? <> · {m.period}</> : null}
{m.category && <> · <span className="muted">{m.category}</span></>}
{m.reference && <> · <span className="muted small">{t('reference')} {m.reference}</span></>}
<p className="claim-detail">
{joined([
amount && <span className="amount">{amount}</span>,
(m.payer_name || m.payee_name) && (
<span className="flow">{m.payer_name ?? '?'} <Icon name="arrow-right" /> {m.payee_name ?? '?'}</span>
),
m.paid_on ? formatDate(m.paid_on, locale) : formatPeriod(m.period, locale),
m.category && <span className="muted">{m.category}</span>,
m.reference && <span className="muted">{t('reference')} <span className="mono">{m.reference}</span></span>
])}
</p>
);
};
@@ -48,31 +58,34 @@ const counterpart: Renderer = (claim, {t}) => {
);
};
const social: Renderer = (claim) => {
const METRICS = ['likes', 'reposts', 'favourites', 'reblogs', 'views'] as const;
const social: Renderer = (claim, {t}) => {
const platform = str(claim.data.platform) ?? claim.platform;
if (!platform) return null;
const metrics = ['likes', 'reposts', 'favourites', 'reblogs', 'views']
.map((key) => (claim.data[key] !== null && claim.data[key] !== undefined ? `${claim.data[key]} ${key}` : null))
.filter(Boolean);
const metrics = METRICS.filter((key) => claim.data[key] !== null && claim.data[key] !== undefined)
.map((key) => `${claim.data[key]} ${t(`metric.${key}`)}`);
return (
<p className="claim-detail small">
<span className={`chip platform platform-${platform}`}>{platform}</span>
{metrics.length > 0 && <span className="muted"> {metrics.join(' · ')}</span>}
<p className="claim-detail cluster">
<PlatformBadge platform={platform} />
{metrics.length > 0 && <span className="muted">{metrics.join(' · ')}</span>}
</p>
);
};
const wikiEdit: Renderer = (claim, {t}) => (
<p className="claim-detail small">
{t('article')}: <strong>{str(claim.data.title)}</strong> ({str(claim.data.wiki)})
{claim.data.sizediff !== undefined && <> · {Number(claim.data.sizediff) > 0 ? '+' : ''}{str(claim.data.sizediff)} Bytes</>}
{str(claim.data.comment) && <> · <em>{str(claim.data.comment)}</em></>}
<p className="claim-detail">
{joined([
<>{t('article')}: <strong>{str(claim.data.title)}</strong> ({str(claim.data.wiki)})</>,
claim.data.sizediff !== undefined && <span className="mono">{Number(claim.data.sizediff) > 0 ? '+' : ''}{str(claim.data.sizediff)} Bytes</span>,
str(claim.data.comment) && <em>{str(claim.data.comment)}</em>
])}
</p>
);
const governmentDecision: Renderer = (claim, {t}) => (
<p className="claim-detail small muted">
{str(claim.data.office)} · {t('fileNumber')} {str(claim.data.zahl)}
<p className="claim-detail muted">
{joined([str(claim.data.office), str(claim.data.zahl) && <>{t('fileNumber')} <span className="mono">{str(claim.data.zahl)}</span></>])}
</p>
);
@@ -82,7 +95,7 @@ const partyFinance: Renderer = (claim, {locale, t}) => {
return (
<details className="claim-detail">
<summary>{t('financeDetails', {year: str(claim.data.year) ?? ''})}</summary>
<table className="compact-table">
<table>
<tbody>
{rows.map(([key, value]) => (
<tr key={key}><td>{key}</td><td className="num">{formatMoney(Number(value), locale)}</td></tr>
+104 -67
View File
@@ -1,8 +1,13 @@
import type {ReactNode} from 'react';
import {getLocale, getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import type {Claim} from '@/lib/api';
import type {Claim, Evidence} from '@/lib/api';
import {formatDate, localized} from '@/lib/format';
import {SourceMeter, TypeLabel} from './badges';
import {ClaimDetails, OrgLink} from './ClaimDetails';
import {ExternalLink} from './ExternalLink';
import {Icon} from './Icon';
import {EmptyState, Notice} from './Notice';
/**
* Legal matters: the presumption of innocence applies to every stage except an
@@ -14,75 +19,107 @@ export function needsInnocenceNotice(claim: Claim): boolean {
return !(claim.legal_stage === 'convicted_final' && claim.status !== 'disputed');
}
export async function ClaimList({claims, showSubject = true}: {claims: Claim[]; showSubject?: boolean}) {
export async function ClaimList({claims, showSubject = true, empty}: {claims: Claim[]; showSubject?: boolean; empty?: ReactNode}) {
const t = await getTranslations('Claims');
const td = await getTranslations('ClaimDetails');
const locale = await getLocale();
if (!claims.length) return <p className="muted">{t('none')}</p>;
const ctx = {locale, t: (key: string, values?: Record<string, string | number>) => td(key as 'with', values)};
if (!claims.length) return <EmptyState icon="document">{empty ?? t('none')}</EmptyState>;
return (
<ul className="claims">
{claims.map((claim) => {
const summary = locale === 'en' && claim.summary_en ? claim.summary_en : claim.summary_de;
const when = claim.occurred_at ?? claim.valid_from;
return (
<li key={claim.id} className={`claim claim-${claim.status}${claim.claim_type === 'legal_proceeding' ? ' claim-legal' : ''}`}>
<div className="claim-head">
<span className={`tag tag-${claim.claim_type}`}>{t(`type.${claim.claim_type}` as 'type.other')}</span>
{when && <time>{formatDate(when, locale)}</time>}
{showSubject && claim.person_slug && (
<Link href={`/personen/${claim.person_slug}`}>{claim.person_name}</Link>
)}
{showSubject && !claim.person_slug && claim.organization_slug && (
<OrgLink slug={claim.organization_slug} name={claim.organization_short ?? claim.organization_name} />
)}
<span
className={`status status-${claim.status}`}
title={claim.status === 'corroborated' ? t('corroboratedBy', {count: claim.source_count}) : undefined}
>
{t(`status.${claim.status}`)}
{claim.status === 'corroborated' && ` (${claim.source_count})`}
</span>
</div>
{claim.legal_stage && (
<p className="legal-stage">
<strong>{t(`legalStage.${claim.legal_stage}`)}</strong>
{claim.legal_offense && <> · {claim.legal_offense}</>}
{claim.legal_authority && <> · {claim.legal_authority}</>}
</p>
)}
<p className="claim-summary">{summary}</p>
<ClaimDetails claim={claim} ctx={ctx} />
{needsInnocenceNotice(claim) && <p className="notice innocence">{t('innocence')}</p>}
{claim.review_status === 'pending' && <p className="notice">{t('pending')}</p>}
{claim.topics && claim.topics.length > 0 && (
<div className="topics">
{claim.topics.map((topic) => (
<span key={topic.slug} className="chip">{localized(topic, locale)}</span>
))}
</div>
)}
<details className="evidence">
<summary>{t('sources', {count: claim.evidence.length})}</summary>
<ol>
{claim.evidence.map((ev, i) => (
<li key={`${ev.url}-${i}`}>
<span className={`source source-${ev.source_kind}`}>{ev.source_name ?? ev.source}</span>{' '}
<a href={ev.url} target="_blank" rel="noopener noreferrer nofollow">
{ev.title || ev.url}
</a>
{ev.published_at && <> · {formatDate(ev.published_at, locale)}</>}
{ev.stance !== 'supports' && <> · <em>{t(ev.stance)}</em></>}
{ev.archive_url && <> · <a href={ev.archive_url} target="_blank" rel="noopener noreferrer">archive</a></>}
{ev.quote && ev.quote !== ev.title && <blockquote>{ev.quote}</blockquote>}
</li>
))}
</ol>
</details>
</li>
);
})}
{claims.map((claim) => (
<li key={claim.id}><ClaimCard claim={claim} showSubject={showSubject} /></li>
))}
</ul>
);
}
/**
* The claim card (docs/design/README.md §6.1). `standalone` renders the permalink page version: the summary as
* the page title and all sources expanded.
*/
export async function ClaimCard({claim, showSubject = true, standalone = false}: {claim: Claim; showSubject?: boolean; standalone?: boolean}) {
const t = await getTranslations('Claims');
const td = await getTranslations('ClaimDetails');
const locale = await getLocale();
const ctx = {locale, t: (key: string, values?: Record<string, string | number>) => td(key as 'with', values)};
const english = locale === 'en' && claim.summary_en;
const summary = english ? claim.summary_en : claim.summary_de;
const summaryLang = locale !== 'de' && !english ? 'de' : undefined;
const when = claim.occurred_at ?? claim.valid_from;
const classes = ['claim', claim.claim_type === 'legal_proceeding' && 'is-legal', claim.status === 'disputed' && 'is-disputed']
.filter(Boolean).join(' ');
return (
<article className={classes} id={standalone ? undefined : `angabe-${claim.id}`}>
<div className="claim-head">
<TypeLabel type={claim.claim_type} />
{when && <time dateTime={when}>{formatDate(when, locale)}</time>}
{showSubject && claim.person_slug && <Link href={`/personen/${claim.person_slug}`}>{claim.person_name}</Link>}
{showSubject && !claim.person_slug && claim.organization_slug && (
<OrgLink slug={claim.organization_slug} name={claim.organization_short ?? claim.organization_name} />
)}
<SourceMeter count={claim.source_count} status={claim.status} />
</div>
{claim.legal_stage && (
<p className="claim-legal">
<span className="badge badge--caution">{t(`legalStage.${claim.legal_stage}` as 'legalStage.investigation')}</span>
{claim.legal_offense && <span>{claim.legal_offense}</span>}
{claim.legal_authority && <span>· {claim.legal_authority}</span>}
</p>
)}
{standalone ? (
<h1 className="claim-summary claim-summary--lg" lang={summaryLang}>{summary}</h1>
) : (
<p className="claim-summary" lang={summaryLang}>{summary}</p>
)}
<ClaimDetails claim={claim} ctx={ctx} />
{needsInnocenceNotice(claim) && <Notice tone="legal" inline>{t('innocence')}</Notice>}
{claim.review_status === 'pending' && <Notice tone="caution" inline>{t('pending')}</Notice>}
{claim.topics && claim.topics.length > 0 && (
<div className="claim-topics cluster">
{claim.topics.map((topic) => <span key={topic.slug} className="chip">{localized(topic, locale)}</span>)}
</div>
)}
{standalone ? (
<EvidenceList evidence={claim.evidence} />
) : (
<div className="claim-foot">
<details className="evidence">
<summary>
<Icon name="document" /> {t('evidence', {count: claim.evidence.length})} <Icon name="chevron-down" />
</summary>
<EvidenceList evidence={claim.evidence} />
</details>
<Link href={`/angaben/${claim.id}`} className="permalink" title={t('permalinkHint')}>
<Icon name="link" /> {t('permalink')}
</Link>
</div>
)}
</article>
);
}
/** Each source: publisher, kind, date, stance, linked title, archived copy, quote. */
export async function EvidenceList({evidence}: {evidence: Evidence[]}) {
const t = await getTranslations('Claims');
const ts = await getTranslations('Settings');
const locale = await getLocale();
return (
<ol className="evidence-list">
{evidence.map((ev, i) => (
<li key={`${ev.url}-${i}`} className="evidence-item">
<span className="evidence-source">
<strong>{ev.source_name ?? ev.source}</strong>
{ev.source_kind && <span className="badge">{ts(`kind.${ev.source_kind}` as 'kind.official')}</span>}
{ev.published_at && <time dateTime={ev.published_at}>{formatDate(ev.published_at, locale)}</time>}
{ev.stance !== 'supports' && <em>{t(ev.stance)}</em>}
</span>
<ExternalLink href={ev.url} className="evidence-title">{ev.title || ev.url}</ExternalLink>
{ev.archive_url && <ExternalLink href={ev.archive_url} className="small">{t('archived')}</ExternalLink>}
{ev.quote && ev.quote !== ev.title && <blockquote className="evidence-quote">{ev.quote}</blockquote>}
</li>
))}
</ol>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type {ReactNode} from 'react';
import {getTranslations} from 'next-intl/server';
import {Icon} from './Icon';
/** Link to a source outside Politrack: new tab, no referrer, no ranking signal, announced to screen readers. */
export async function ExternalLink({href, children, className, icon = true}: {
href: string;
children: ReactNode;
className?: string;
icon?: boolean;
}) {
const t = await getTranslations('Common');
return (
<a href={href} className={className} target="_blank" rel="noopener noreferrer nofollow">
{children}
{icon && <> <Icon name="external" /></>}
<span className="visually-hidden"> ({t('newTab')})</span>
</a>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type {ReactNode} from 'react';
import {getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import {Icon} from './Icon';
/**
* Page filters as a plain GET form: the URL is the state, so every view can be shared. On phones the fields
* collapse behind a "Filter" toggle (a checkbox + label, no JavaScript) that starts open when filters are active.
*/
export async function FilterBar({id, active, resetHref, hidden = {}, children}: {
id: string;
active: number;
resetHref?: string;
hidden?: Record<string, string | undefined>;
children: ReactNode;
}) {
const t = await getTranslations('Common');
const toggle = `${id}-toggle`;
return (
<form className="filter-bar" method="get" aria-label={t('filters')}>
<input type="checkbox" id={toggle} className="filter-toggle visually-hidden" defaultChecked={active > 0} />
<label htmlFor={toggle} className="filter-toggle-label">
<Icon name="sliders" /> {t('filters')}
{active > 0 && <span className="count-badge">{active}</span>}
<Icon name="chevron-down" className="icon-chevron" />
</label>
<div className="filter-fields">
{Object.entries(hidden).map(([name, value]) => value ? <input key={name} type="hidden" name={name} value={value} /> : null)}
{children}
<div className="filter-actions">
<button type="submit">{t('apply')}</button>
{active > 0 && resetHref && <Link href={resetHref}>{t('reset')}</Link>}
</div>
</div>
</form>
);
}
/** A labelled form field inside a FilterBar. */
export function Field({label, wide = false, children}: {label: string; wide?: boolean; children: ReactNode}) {
return (
<label className={wide ? 'field field--wide' : 'field'}>
<span className="field-label">{label}</span>
{children}
</label>
);
}
/** Number of non-empty values - the "active filters" count. */
export function countActive(...values: (string | undefined | null)[]): number {
return values.filter((v) => v !== undefined && v !== null && v !== '').length;
}
+91
View File
@@ -0,0 +1,91 @@
// Inline SVG icons on a 24 px grid, drawn with strokes in currentColor (no icon font, no external files).
// Decorative by default (aria-hidden); pass `label` when the icon is the only content of a control.
// To add one: a list of path `d` strings (and circles as `c:cx,cy,r`) - see docs/design/design-system.md.
const PATHS = {
search: ['c:11,11,7', 'M16.2 16.2L21 21'],
person: ['c:12,8,4', 'M4 21c0-4.2 3.6-7 8-7s8 2.8 8 7'],
people: ['c:9,8,3.5', 'M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6', 'c:17,9,2.5', 'M16.5 14.1c2.9.3 5 2.4 5 5.9'],
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
building: ['M4 21V5.5L12 3v18', 'M12 9l8 2v10', 'M2 21h20', 'M7.5 8h1.5M7.5 12h1.5M7.5 16h1.5M15.5 14h1.5M15.5 17.5h1.5'],
euro: ['M17.5 6.2A7.5 7.5 0 1 0 17.5 17.8', 'M4.5 10h9M4.5 14h9'],
vote: ['M3.5 6.5a3 3 0 0 1 3-3h11a3 3 0 0 1 3 3v11a3 3 0 0 1-3 3h-11a3 3 0 0 1-3-3z', 'M8 12.5l2.7 2.7L16.5 9.4'],
calendar: ['M3.5 7a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z', 'M3.5 10h17', 'M8 3v4M16 3v4'],
message: ['M4 5.5a1.5 1.5 0 0 1 1.5-1.5h13A1.5 1.5 0 0 1 20 5.5v9a1.5 1.5 0 0 1-1.5 1.5H9.5L5 20v-4h-.5A.5.5 0 0 1 4 15.5z'],
book: ['M3.5 5.5A1.5 1.5 0 0 1 5 4h4.5A2.5 2.5 0 0 1 12 6.5V20a2 2 0 0 0-2-2H3.5z',
'M20.5 5.5A1.5 1.5 0 0 0 19 4h-4.5A2.5 2.5 0 0 0 12 6.5V20a2 2 0 0 1 2-2h6.5z'],
history: ['M3.5 12a8.5 8.5 0 1 0 2.6-6.1', 'M3.5 4v4.5H8', 'M12 7.5V12l3 2'],
sliders: ['M4 7h9M17 7h3', 'c:15,7,2', 'M4 12h3M11 12h9', 'c:9,12,2', 'M4 17h11M19 17h1', 'c:17,17,2'],
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v4.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10'],
document: ['M6 3h8l4 4v14H6z', 'M14 3v4h4', 'M9 12h6M9 16h6'],
check: ['M5 12.5l4.5 4.5L19 7.5'],
checks: ['M2.5 12.5L7 17l9.5-9.5', 'M12.5 16l1 1L23 7.5'],
alert: ['M10.3 4.2a2 2 0 0 1 3.4 0l7.9 13.6a2 2 0 0 1-1.7 3H4.1a2 2 0 0 1-1.7-3z', 'M12 9.5v4.5', 'M12 17.2v.1'],
scale: ['M12 4v16', 'M7.5 20h9', 'M4.5 7h15', 'M4.5 7L2 13M4.5 7L7 13', 'M19.5 7L17 13M19.5 7L22 13',
'M2 13a2.5 2.5 0 0 0 5 0z', 'M17 13a2.5 2.5 0 0 0 5 0z'],
globe: ['c:12,12,9', 'M3 12h18', 'M12 3c2.4 2.5 3.6 5.5 3.6 9s-1.2 6.5-3.6 9c-2.4-2.5-3.6-5.5-3.6-9S9.6 5.5 12 3z'],
sun: ['c:12,12,4', 'M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4'],
moon: ['M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z'],
monitor: ['M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', 'M8.5 20h7M12 16v4'],
'arrow-right': ['M5 12h14', 'M13 6l6 6-6 6'],
'arrow-left': ['M19 12H5', 'M11 6l-6 6 6 6'],
'chevron-down': ['M6 9l6 6 6-6'],
'chevron-right': ['M9 6l6 6-6 6'],
link: ['M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1', 'M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1'],
quote: ['M5 18h4.5v-4.5H6.5c0-3 1-4.6 3-5.5', 'M14.5 18H19v-4.5h-3c0-3 1-4.6 3-5.5'],
mic: ['M9 6a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0z', 'M5.5 11a6.5 6.5 0 0 0 13 0', 'M12 17.5V21'],
plane: ['M3 10.5L21 3l-7.5 18-2.8-7.7z', 'M10.7 13.3L21 3'],
briefcase: ['M3.5 9a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z',
'M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7', 'M3.5 13h17'],
landmark: ['M3 21h18', 'M12 3l9 5H3z', 'M5 11v7M9.7 11v7M14.3 11v7M19 11v7', 'M4 18h16'],
info: ['c:12,12,9', 'M12 11v6', 'M12 7.6v.1'],
close: ['M6 6l12 12M18 6L6 18'],
menu: ['M4 7h16M4 12h16M4 17h16'],
pin: ['M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z', 'c:12,9.5,2.5'],
news: ['M4 5h12.5v13.5A2.5 2.5 0 0 0 19 21H6a2 2 0 0 1-2-2z', 'M16.5 9H20v9.5a2.5 2.5 0 0 1-2.5 2.5', 'M7.5 9h5.5M7.5 13h5.5M7.5 17h3'],
question: ['c:12,12,9', 'M9.6 9.4a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1.1 1-1.1 1.7v.5', 'M12 16.8v.1'],
pencil: ['M4 20h4L19 9l-4-4L4 16z', 'M13.5 6.5l4 4'],
pie: ['M12 3a9 9 0 1 0 9 9h-9z', 'M15 3.5A9 9 0 0 1 20.5 9H15z'],
activity: ['M3 12h4l3 7 4-14 3 7h4'],
shield: ['M12 3l7.5 3v5.5c0 4.6-3.2 8.3-7.5 9.5-4.3-1.2-7.5-4.9-7.5-9.5V6z', 'M8.8 12l2.3 2.3 4.2-4.3'],
code: ['M8.5 7L3.5 12l5 5', 'M15.5 7l5 5-5 5']
} as const;
export type IconName = keyof typeof PATHS;
export function Icon({name, label, className}: {name: IconName; label?: string; className?: string}) {
return (
<svg
className={className ? `icon icon-${name} ${className}` : `icon icon-${name}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={label ? undefined : true}
role={label ? 'img' : undefined}
aria-label={label}
focusable="false"
>
{PATHS[name].map((d, i) => {
if (d.startsWith('c:')) {
const [cx, cy, r] = d.slice(2).split(',');
return <circle key={i} cx={cx} cy={cy} r={r} />;
}
return <path key={i} d={d} />;
})}
</svg>
);
}
/** The Politrack mark: citation brackets around a piece of evidence. Also the favicon (src/app/icon.svg). */
export function BrandMark() {
return (
<svg className="brand-mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M11 5H6v22h5" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 5h5v22h-5" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
<circle cx="16" cy="16" r="3.75" fill="currentColor" />
</svg>
);
}
+6 -2
View File
@@ -3,7 +3,9 @@
import {useLocale, useTranslations} from 'next-intl';
import {useSearchParams} from 'next/navigation';
import {Link, usePathname} from '@/i18n/navigation';
import {Icon} from './Icon';
/** Same page in the other language (routes are identical, only the /en prefix changes). */
export function LocaleSwitcher() {
const t = useTranslations('Nav');
const locale = useLocale();
@@ -11,8 +13,10 @@ export function LocaleSwitcher() {
const search = useSearchParams().toString();
const target = locale === 'de' ? 'en' : 'de';
return (
<Link href={search ? `${pathname}?${search}` : pathname} locale={target} hrefLang={target}>
{t('language')}
<Link href={search ? `${pathname}?${search}` : pathname} locale={target} hrefLang={target} lang={target}
className="icon-button" aria-label={t('language')} title={t('language')}>
<Icon name="globe" />
<span>{target.toUpperCase()}</span>
</Link>
);
}
+51 -31
View File
@@ -1,41 +1,61 @@
import {getLocale, getTranslations} from 'next-intl/server';
import type {MoneyFlow} from '@/lib/api';
import {formatDate, formatMoney} from '@/lib/format';
import {formatDate, formatMoney, formatPeriod} from '@/lib/format';
import {OrgLink} from './ClaimDetails';
import {EmptyState} from './Notice';
/** Money flows from official registers: who paid whom, how much, when - each row with its sources. */
export async function MoneyTable({flows}: {flows: MoneyFlow[]}) {
/**
* Money flows from official registers: who paid whom, how much, when - each row with its sources.
* Exact amounts (as reported); on phones the rows stack into labelled cards.
*/
export async function MoneyTable({flows, self}: {flows: MoneyFlow[]; self?: string}) {
const t = await getTranslations('Money');
const locale = await getLocale();
if (!flows.length) return <p className="muted">{t('none')}</p>;
if (!flows.length) return <EmptyState icon="euro">{t('none')}</EmptyState>;
return (
<table className="money">
<thead>
<tr>
<th>{t('date')}</th><th>{t('kindLabel')}</th><th>{t('payer')}</th><th>{t('payee')}</th>
<th className="num">{t('amount')}</th><th>{t('sources')}</th>
</tr>
</thead>
<tbody>
{flows.map((flow) => (
<tr key={flow.id}>
<td className="nowrap">{flow.paid_on ? formatDate(flow.paid_on, locale) : flow.period ?? formatDate(flow.occurred_at, locale)}</td>
<td><span className={`tag tag-money-${flow.kind}`}>{t(`kind.${flow.kind}` as 'kind.other')}</span></td>
<td><OrgLink slug={flow.payer_slug} name={flow.payer_name} /></td>
<td><OrgLink slug={flow.payee_slug} name={flow.payee_name} /></td>
<td className="num nowrap">
{flow.amount !== null && flow.amount !== undefined ? formatMoney(flow.amount, locale) : flow.amount_category}
</td>
<td className="small">
{flow.evidence.map((ev, i) => (
<a key={`${ev.url}-${i}`} href={ev.url} className="cite" target="_blank" rel="noopener noreferrer">
[{ev.source}]
</a>
))}
</td>
<div className="table-wrap">
<table className="table-stack">
<thead>
<tr>
<th scope="col">{t('date')}</th>
<th scope="col">{t('payer')}</th>
<th scope="col">{t('payee')}</th>
<th scope="col" className="num">{t('amount')}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{flows.map((flow) => (
<tr key={flow.id}>
<td data-label={t('date')}>
<span className="nowrap">
{flow.paid_on ? formatDate(flow.paid_on, locale) : formatPeriod(flow.period, locale) || formatDate(flow.occurred_at, locale)}
</span>
<span className="block"><span className="badge">{t(`kind.${flow.kind}` as 'kind.other')}</span></span>
</td>
<td data-label={t('payer')}><Party slug={flow.payer_slug} name={flow.payer_name} self={self} /></td>
<td data-label={t('payee')}><Party slug={flow.payee_slug} name={flow.payee_name} self={self} /></td>
<td data-label={t('amount')} className="num">
<span className="amount">
{flow.amount !== null && flow.amount !== undefined ? formatMoney(flow.amount, locale) : flow.amount_category}
</span>
<span className="cite-list money-sources" aria-label={t('sources')}>
{flow.evidence.map((ev, i) => (
<a key={`${ev.url}-${i}`} href={ev.url} className="cite" target="_blank" rel="noopener noreferrer nofollow"
title={[ev.source_name, ev.title].filter(Boolean).join(': ') || undefined}>
{ev.source_name ?? ev.source}
</a>
))}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/** One side of a flow; on an organisation's own page its name is plain text, not a link to itself. */
function Party({slug, name, self}: {slug: string | null; name: string | null; self?: string}) {
return slug && slug === self ? <span className="muted">{name}</span> : <OrgLink slug={slug} name={name} />;
}
+15
View File
@@ -0,0 +1,15 @@
'use client';
import type {ReactNode} from 'react';
import {Link, usePathname} from '@/i18n/navigation';
/** A navigation link that marks itself as the current page (also for sub-pages: /personen/x → Personen). */
export function NavLink({href, children, className = 'nav-link'}: {href: string; children: ReactNode; className?: string}) {
const pathname = usePathname();
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link href={href} className={className} aria-current={active ? 'page' : undefined}>
{children}
</Link>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type {ReactNode} from 'react';
import {Icon, type IconName} from './Icon';
type Tone = 'note' | 'info' | 'caution' | 'legal';
const ICONS: Record<Tone, IconName> = {note: 'info', info: 'info', caution: 'alert', legal: 'scale'};
/**
* A short note next to the content it is about: info (filters, explanations), caution (disputed, unverified),
* legal (presumption of innocence), note (neutral). `inline` for notices inside claim cards.
*/
export function Notice({tone = 'note', inline = false, children}: {tone?: Tone; inline?: boolean; children: ReactNode}) {
return (
<div className={`notice notice--${tone}${inline ? ' notice--inline' : ''}`} role="note">
<Icon name={ICONS[tone]} />
<div className="notice-body">{children}</div>
</div>
);
}
/** Nothing to show: say why and what to do next. */
export function EmptyState({icon = 'search', children, action}: {icon?: IconName; children: ReactNode; action?: ReactNode}) {
return (
<div className="empty-state">
<Icon name={icon} />
<p>{children}</p>
{action}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type {ReactNode} from 'react';
import {getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
export type Crumb = {label: string; href?: string};
/** Start › Section › Page. The home crumb is added automatically; the last crumb is the current page. */
export async function Breadcrumbs({items}: {items: Crumb[]}) {
const t = await getTranslations('Common');
const all: Crumb[] = [{label: t('home'), href: '/'}, ...items];
return (
<nav className="breadcrumbs" aria-label={t('breadcrumb')}>
<ol>
{all.map((crumb, i) => (
<li key={`${crumb.label}-${i}`}>
{crumb.href && i < all.length - 1 ? (
<Link href={crumb.href}>{crumb.label}</Link>
) : (
<span aria-current={i === all.length - 1 ? 'page' : undefined}>{crumb.label}</span>
)}
</li>
))}
</ol>
</nav>
);
}
/** Title block of index, detail and prose pages (profiles have their own header). */
export async function PageHeader({title, eyebrow, lede, meta, actions, crumbs}: {
title: ReactNode;
eyebrow?: ReactNode;
lede?: ReactNode;
meta?: ReactNode;
actions?: ReactNode;
crumbs?: Crumb[];
}) {
return (
<>
{crumbs && <Breadcrumbs items={crumbs} />}
<header className="page-header">
<div className="page-header-main">
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
<h1>{title}</h1>
{lede && <p className="lede">{lede}</p>}
{meta && <p className="page-header-meta">{meta}</p>}
</div>
{actions && <div className="page-header-actions">{actions}</div>}
</header>
</>
);
}
/** h2 with an optional "Alle …" link on the right. */
export function SectionHead({title, id, more, children}: {
title: ReactNode;
id?: string;
more?: {href: string; label: string};
children?: ReactNode;
}) {
return (
<div className="section-head">
<h2 id={id}>{title}</h2>
{children}
{more && (
<Link href={more.href} className="more-link">
{more.label} <span aria-hidden="true">→</span>
</Link>
)}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import {getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import {Icon} from './Icon';
type Query = Record<string, string | undefined>;
/** Previous / page n / next. The API has no totals, so "next" appears when the page was full. */
export async function Pagination({pathname, query, page, hasNext}: {pathname: string; query: Query; page: number; hasNext: boolean}) {
if (page <= 1 && !hasNext) return null;
const t = await getTranslations('Common');
const clean = Object.fromEntries(Object.entries(query).filter(([, v]) => v)) as Record<string, string>;
const href = (p: number) => ({pathname, query: {...clean, page: String(p)}});
return (
<nav className="pagination" aria-label={t('pagination')}>
{page > 1 ? (
<Link href={href(page - 1)} rel="prev"><Icon name="arrow-left" /> {t('previous')}</Link>
) : <span className="is-hidden" aria-hidden="true" />}
<span>{t('page', {page})}</span>
{hasNext ? (
<Link href={href(page + 1)} rel="next">{t('next')} <Icon name="arrow-right" /></Link>
) : <span className="is-hidden" aria-hidden="true" />}
</nav>
);
}
+33
View File
@@ -0,0 +1,33 @@
import {getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import type {PersonListItem} from '@/lib/api';
import {Avatar} from './Avatar';
import {PartyTag} from './badges';
/** A person in a grid: the whole card is one link, so the party tags inside are plain labels. */
export async function PersonCard({person}: {person: PersonListItem}) {
const t = await getTranslations('Persons');
// Role codes from the API ('mp') get a readable label; other roles are already titles.
const roles = (person.current_roles ?? []).map((role) => (role === 'mp' ? t('roleMp') : role)).slice(0, 2).join(' · ');
const parties = (person.parties ?? []).filter((party) => party.short_name);
return (
<Link href={`/personen/${person.slug}`} className="person-card">
<Avatar name={person.full_name} src={person.photo_url} />
<span className="person-card-body">
<span className="person-card-name">{person.full_name}</span>
<span className="person-card-roles">
{person.status === 'active'
? roles
: <>{t(`statusValue.${person.status}`)}{person.last_office_end && ` · ${t('lastOffice', {date: person.last_office_end.slice(0, 4)})}`}</>}
</span>
{parties.length > 0 && (
<span className="cluster">
{parties.map((party) => (
<PartyTag key={party.slug} short={party.short_name} slug={party.slug} color={party.color} link={false} />
))}
</span>
)}
</span>
</Link>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type {CSSProperties} from 'react';
import {getTranslations} from 'next-intl/server';
import type {Role} from '@/lib/api';
import {roleLabel} from '@/lib/roles';
const MAX_ROWS = 14;
/**
* Offices and memberships as bars on a year axis (docs/design/README.md §6.7). Party memberships use the party
* colour, offices the accent. Roles without a start date can't be placed and are listed in the roles panel only.
*/
export async function RoleTimeline({roles, now = new Date()}: {roles: Role[]; now?: Date}) {
const t = await getTranslations('Person');
const dated = roles
.filter((r) => r.start_date)
.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
.slice(-MAX_ROWS);
if (dated.length < 2) return null;
const year = (value: string) => {
const d = new Date(value);
return d.getUTCFullYear() + d.getUTCMonth() / 12;
};
const end = now.getUTCFullYear() + now.getUTCMonth() / 12 + 0.5;
const start = Math.floor(Math.max(Math.min(...dated.map((r) => year(r.start_date!))), end - 40));
const span = end - start;
const pct = (value: number) => `${(((Math.max(value, start) - start) / span) * 100).toFixed(2)}%`;
const step = span > 24 ? 10 : span > 10 ? 5 : span > 4 ? 2 : 1;
const ticks: number[] = [];
for (let y = Math.ceil(start / step) * step; y < end; y += step) ticks.push(y);
return (
<figure className="role-timeline">
<figcaption className="visually-hidden">{t('career')}</figcaption>
{dated.map((role, i) => {
const from = year(role.start_date!);
const current = !role.end_date || new Date(role.end_date) >= now;
const to = current ? end : year(role.end_date!);
const style = {
'--start': pct(from),
'--len': `${Math.max(((to - Math.max(from, start)) / span) * 100, 0.8).toFixed(2)}%`,
...(role.level === 'party' && role.party_color ? {'--party': role.party_color} : {})
} as CSSProperties;
const period = `${role.start_date!.slice(0, 4)}–${current ? '' : role.end_date!.slice(0, 4)}`;
return (
<div key={i} className="rt-row">
<span className="rt-label" title={`${roleLabel(role)} (${period})`}>
{roleLabel(role)} <span className="muted">{period}</span>
</span>
<span className="rt-track"><span className={current ? 'rt-bar is-current' : 'rt-bar'} style={style} /></span>
</div>
);
})}
<div className="rt-row rt-row--axis" aria-hidden="true">
<span />
<span className="rt-axis">
{ticks.map((tick) => <span key={tick} style={{left: pct(tick)}}>{tick}</span>)}
</span>
</div>
</figure>
);
}
+21 -12
View File
@@ -1,28 +1,37 @@
import {getLocale, getTranslations} from 'next-intl/server';
import {getPathname} from '@/i18n/navigation';
import {Icon} from './Icon';
export async function SearchForm({defaultValue = '', since, until, withDates = false}: {
/** The big search field (home, search page). With dates, an empty query becomes "search in time". */
export async function SearchForm({defaultValue = '', since, until, withDates = false, autoFocus = false}: {
defaultValue?: string;
since?: string;
until?: string;
withDates?: boolean;
autoFocus?: boolean;
}) {
const t = await getTranslations('Search');
const locale = await getLocale();
return (
<form className="search" action={getPathname({href: '/suche', locale})} method="get" role="search">
<input type="search" name="q" defaultValue={defaultValue} placeholder={t('placeholder')} minLength={2} required={!withDates} />
{withDates && (
<>
<label className="small">
{t('from')} <input type="date" name="since" defaultValue={since} />
</label>
<label className="small">
{t('to')} <input type="date" name="until" defaultValue={until} />
</label>
</>
)}
<div className="search-main">
<Icon name="search" />
<input type="search" name="q" defaultValue={defaultValue} placeholder={t('placeholder')} aria-label={t('label')}
minLength={2} required={!withDates} autoFocus={autoFocus} />
</div>
<button type="submit">{t('submit')}</button>
{withDates && (
<div className="search-dates">
<label className="field">
<span className="field-label">{t('from')}</span>
<input type="date" name="since" defaultValue={since} />
</label>
<label className="field">
<span className="field-label">{t('to')}</span>
<input type="date" name="until" defaultValue={until} />
</label>
</div>
)}
</form>
);
}
+38 -32
View File
@@ -1,12 +1,17 @@
'use client';
import type {CSSProperties} from 'react';
import {useLocale, useTranslations} from 'next-intl';
import {useState} from 'react';
import {useRouter} from '@/i18n/navigation';
import type {Source, Topic} from '@/lib/api';
import {localized} from '@/lib/format';
import {DEFAULT_PREFS, PREFS_COOKIE, serializePrefs, type Prefs} from '@/lib/prefs';
import {Icon} from './Icon';
const KIND_ORDER = ['official', 'ngo', 'media', 'aggregator', 'social', 'user', 'derived'];
/** "My filters": trusted sources (switches), minimum trust, topics (chips). Stored in a cookie only. */
export function SettingsForm({sources, topics, initial}: {sources: Source[]; topics: Topic[]; initial: Prefs}) {
const t = useTranslations('Settings');
const locale = useLocale();
@@ -14,12 +19,17 @@ export function SettingsForm({sources, topics, initial}: {sources: Source[]; top
const [prefs, setPrefs] = useState<Prefs>(initial);
const [saved, setSaved] = useState(false);
const kinds = [...new Set(sources.map((s) => s.kind))];
const kinds = [...new Set(sources.map((s) => s.kind))].sort((a, b) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b));
function toggle(list: string[], value: string): string[] {
return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
}
function update(next: Prefs) {
setSaved(false);
setPrefs(next);
}
function save(next: Prefs) {
document.cookie = `${PREFS_COOKIE}=${serializePrefs(next)}; path=/; max-age=31536000; samesite=lax`;
setPrefs(next);
@@ -35,61 +45,57 @@ export function SettingsForm({sources, topics, initial}: {sources: Source[]; top
save(prefs);
}}
>
<fieldset>
<fieldset className="settings-section">
<legend>{t('sources')}</legend>
<p className="muted small">{t('sourcesHint')}</p>
{kinds.map((kind) => (
<div key={kind} className="source-group">
<h3>{t(`kind.${kind}` as 'kind.official')}</h3>
{sources.filter((s) => s.kind === kind).map((source) => (
<label key={source.slug} className="check" title={source.tos_notes ?? undefined}>
<label key={source.slug} className="switch-row" title={source.tos_notes ?? undefined}>
<input
type="checkbox"
className="switch"
checked={!prefs.excludedSources.includes(source.slug)}
onChange={() => {
setSaved(false);
setPrefs({...prefs, excludedSources: toggle(prefs.excludedSources, source.slug)});
}}
/>{' '}
{source.name} <span className="muted small">({source.default_trust})</span>
onChange={() => update({...prefs, excludedSources: toggle(prefs.excludedSources, source.slug)})}
/>
<span className="switch-name">{source.name}</span>
<span className="trust-meter" title={t('trust')}>
<span><span style={{'--value': `${source.default_trust}%`} as CSSProperties} /></span>
{source.default_trust}
</span>
</label>
))}
</div>
))}
<label>
{t('minTrust')}: {prefs.minTrust}{' '}
<input
type="range" min={0} max={100} step={5} value={prefs.minTrust}
onChange={(e) => {
setSaved(false);
setPrefs({...prefs, minTrust: Number(e.target.value)});
}}
/>
<label className="range-field">
<span className="strong">{t('minTrust')}</span>
<input type="range" min={0} max={100} step={5} value={prefs.minTrust}
onChange={(e) => update({...prefs, minTrust: Number(e.target.value)})} />
<output className="badge badge--accent">{prefs.minTrust}</output>
</label>
</fieldset>
<fieldset>
<fieldset className="settings-section">
<legend>{t('topics')}</legend>
<p className="muted small">{t('topicsHint')}</p>
<div className="topic-grid">
<div className="chip-toggles">
{topics.map((topic) => (
<label key={topic.slug} className="check">
<input
type="checkbox"
checked={prefs.topics.includes(topic.slug)}
onChange={() => {
setSaved(false);
setPrefs({...prefs, topics: toggle(prefs.topics, topic.slug)});
}}
/>{' '}
<label key={topic.slug} className="chip-toggle">
<input type="checkbox" className="visually-hidden" checked={prefs.topics.includes(topic.slug)}
onChange={() => update({...prefs, topics: toggle(prefs.topics, topic.slug)})} />
<Icon name="check" />
{localized(topic, locale)}
</label>
))}
</div>
</fieldset>
<button type="submit">{t('save')}</button>{' '}
<button type="button" className="secondary" onClick={() => save(DEFAULT_PREFS)}>{t('reset')}</button>{' '}
{saved && <span role="status">{t('saved')}</span>}
<div className="sticky-actions">
<button type="submit">{t('save')}</button>
<button type="button" className="secondary" onClick={() => save(DEFAULT_PREFS)}>{t('reset')}</button>
<span role="status" className="small muted">{saved && <><Icon name="check" /> {t('saved')}</>}</span>
</div>
</form>
);
}
+41
View File
@@ -0,0 +1,41 @@
import {getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import {BrandMark, Icon} from './Icon';
import {NAV_GROUPS} from './nav';
/** Footer: the principles in three lines, the full sitemap, a pointer for developers and agents. */
export async function SiteFooter() {
const t = await getTranslations('Nav');
const tl = await getTranslations('Layout');
const tf = await getTranslations('Footer');
return (
<footer className="site-footer">
<div className="site-footer-inner">
<div className="footer-brand">
<Link href="/" className="brand"><BrandMark /><span>politrack</span></Link>
<p>{tf('tagline')}</p>
<ul className="footer-principles">
<li><Icon name="document" /> {tf('principleEvidence')}</li>
<li><Icon name="scale" /> {tf('principleInnocence')}</li>
<li><Icon name="shield" /> {tf('principlePrivacy')}</li>
</ul>
</div>
<nav className="footer-nav" aria-label={tl('footerNav')}>
{NAV_GROUPS.map((group) => (
<div key={group.key}>
<h2>{tl(`group.${group.key}`)}</h2>
<ul>
{group.items.map((item) => (
<li key={item.href}><Link href={item.href}>{t(item.key)}</Link></li>
))}
{group.key === 'about' && <li><Link href="/methodik#daten">{tf('developers')}</Link></li>}
</ul>
</div>
))}
</nav>
</div>
<p className="footer-bottom"><span>{tf('bottom')}</span></p>
</footer>
);
}
+86
View File
@@ -0,0 +1,86 @@
import {Suspense} from 'react';
import {getLocale, getTranslations} from 'next-intl/server';
import {getPathname, Link} from '@/i18n/navigation';
import type {Theme} from '@/lib/prefs';
import {BrandMark, Icon} from './Icon';
import {LocaleSwitcher} from './LocaleSwitcher';
import {MORE_NAV, NAV_GROUPS, PRIMARY_NAV} from './nav';
import {NavLink} from './NavLink';
import {ThemeSwitcher} from './ThemeSwitcher';
/**
* Sticky header: brand, primary navigation with "Mehr", search, "Meine Filter" (with the number of active
* filters), language and theme. Below 1024 px the navigation moves into a disclosure menu (no JavaScript).
*/
export async function SiteHeader({theme, filterCount}: {theme: Theme; filterCount: number}) {
const t = await getTranslations('Nav');
const tl = await getTranslations('Layout');
const locale = await getLocale();
return (
<header className="site-header">
<div className="site-header-inner">
<Link href="/" className="brand" aria-label={tl('homeLabel')}>
<BrandMark />
<span>politrack</span>
</Link>
<nav className="main-nav" aria-label={tl('mainNav')}>
<ul>
{PRIMARY_NAV.map((item) => (
<li key={item.href}><NavLink href={item.href}>{t(item.key)}</NavLink></li>
))}
<li>
<details className="nav-more">
<summary>{tl('more')} <Icon name="chevron-down" /></summary>
<ul className="nav-more-panel">
{MORE_NAV.map((item) => (
<li key={item.href}>
<NavLink href={item.href}><Icon name={item.icon} /> {t(item.key)}</NavLink>
</li>
))}
</ul>
</details>
</li>
</ul>
</nav>
<div className="header-tools">
<form className="header-search" role="search" action={getPathname({href: '/suche', locale})} method="get">
<label className="visually-hidden" htmlFor="header-q">{tl('searchLabel')}</label>
<input id="header-q" type="search" name="q" placeholder={tl('searchPlaceholder')} minLength={2} required />
<button type="submit" className="icon-button" aria-label={tl('searchLabel')}><Icon name="search" /></button>
</form>
<Link href="/suche" className="icon-button header-search-link" aria-label={tl('searchLabel')}>
<Icon name="search" />
</Link>
<Link href="/einstellungen" className="icon-button"
aria-label={filterCount ? tl('filtersActive', {count: filterCount}) : t('settings')}>
<Icon name="sliders" />
<span className="tool-label">{t('settings')}</span>
{filterCount > 0 && <span className="count-badge" aria-hidden="true">{filterCount}</span>}
</Link>
<Suspense>
<LocaleSwitcher />
</Suspense>
<ThemeSwitcher initial={theme} />
<details className="menu">
<summary className="icon-button" aria-label={tl('menu')}><Icon name="menu" /></summary>
<div className="menu-panel">
{NAV_GROUPS.map((group) => (
<div key={group.key} className="menu-group">
<p className="menu-group-title">{tl(`group.${group.key}`)}</p>
<ul>
{group.items.map((item) => (
<li key={item.href}><NavLink href={item.href}><Icon name={item.icon} /> {t(item.key)}</NavLink></li>
))}
</ul>
</div>
))}
</div>
</details>
</div>
</div>
</header>
);
}
+24 -18
View File
@@ -2,30 +2,36 @@ import {getLocale, getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import type {SocialPost} from '@/lib/api';
import {formatDate} from '@/lib/format';
import {PlatformBadge} from './badges';
import {ExternalLink} from './ExternalLink';
import {EmptyState} from './Notice';
/** Public posts (Bluesky, Mastodon, Telegram channels, YouTube, X via API) with a link to the original. */
/**
* Public posts (Bluesky, Mastodon, Telegram channels, YouTube, X via API) as text cards with a link to the
* original - no embeds, no third-party scripts or images.
*/
export async function SocialFeed({posts, showAuthor = true}: {posts: SocialPost[]; showAuthor?: boolean}) {
const t = await getTranslations('Social');
const locale = await getLocale();
if (!posts.length) return <p className="muted">{t('none')}</p>;
if (!posts.length) return <EmptyState icon="message">{t('none')}</EmptyState>;
return (
<ul className="claims social-feed">
<ul className="feed-grid">
{posts.map((post) => (
<li key={post.id} className="claim">
<div className="claim-head">
<span className={`chip platform platform-${post.platform}`}>{post.platform}</span>
{post.said_at && <time>{formatDate(post.said_at, locale)}</time>}
{showAuthor && post.person_slug && <Link href={`/personen/${post.person_slug}`}>{post.person_name}</Link>}
{showAuthor && !post.person_slug && post.org_slug && (
<Link href={`/organisationen/${post.org_slug}`}>{post.org_name}</Link>
)}
{post.data.account_verified === false && <span className="muted small" title={t('unverifiedHint')}>{t('unverified')}</span>}
</div>
{post.title && <p><strong>{post.title}</strong></p>}
{post.excerpt && <p className="claim-summary post-text">{post.excerpt}</p>}
<p className="small">
<a href={post.url} target="_blank" rel="noopener noreferrer nofollow">{t('original')}</a>
</p>
<li key={post.id}>
<article className="post">
<div className="post-head">
<PlatformBadge platform={post.platform} />
{showAuthor && post.person_slug && <Link href={`/personen/${post.person_slug}`}>{post.person_name}</Link>}
{showAuthor && !post.person_slug && post.org_slug && <Link href={`/organisationen/${post.org_slug}`}>{post.org_name}</Link>}
{post.said_at && <time dateTime={post.said_at}>{formatDate(post.said_at, locale)}</time>}
{post.data.account_verified === false && (
<span className="badge badge--dashed" title={t('unverifiedHint')}>{t('unverified')}</span>
)}
</div>
{post.title && <p className="post-title">{post.title}</p>}
{post.excerpt && <p className="post-text">{post.excerpt}</p>}
<p className="post-foot"><ExternalLink href={post.url}>{t('original')}</ExternalLink></p>
</article>
</li>
))}
</ul>
+18
View File
@@ -0,0 +1,18 @@
import {Link} from '@/i18n/navigation';
import {formatNumber} from '@/lib/format';
export type TabItem = {key: string; label: string; href: string; count?: number};
/** Tabs are links (shareable URLs, no JavaScript); the active one has aria-current. */
export function Tabs({items, current, label, locale}: {items: TabItem[]; current: string; label: string; locale: string}) {
return (
<nav className="tabs" aria-label={label}>
{items.map((item) => (
<Link key={item.key} href={item.href} className="tab" aria-current={item.key === current ? 'page' : undefined}>
{item.label}
{item.count !== undefined && item.count > 0 && <span className="tab-count">{formatNumber(item.count, locale)}</span>}
</Link>
))}
</nav>
);
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import {useTranslations} from 'next-intl';
import {useState} from 'react';
import {THEME_COOKIE, type Theme} from '@/lib/prefs';
import {Icon} from './Icon';
const NEXT: Record<Theme, Theme> = {system: 'light', light: 'dark', dark: 'system'};
const ICONS = {system: 'monitor', light: 'sun', dark: 'moon'} as const;
/** Cycles system → light → dark. The choice lives in a cookie so the server renders the right theme. */
export function ThemeSwitcher({initial}: {initial: Theme}) {
const t = useTranslations('Layout');
const [theme, setTheme] = useState<Theme>(initial);
function cycle() {
const next = NEXT[theme];
const root = document.documentElement;
if (next === 'system') {
root.removeAttribute('data-theme');
document.cookie = `${THEME_COOKIE}=; path=/; max-age=0; samesite=lax`;
} else {
root.dataset.theme = next;
document.cookie = `${THEME_COOKIE}=${next}; path=/; max-age=31536000; samesite=lax`;
}
setTheme(next);
}
const label = t('themeLabel', {current: t(`theme.${theme}`), next: t(`theme.${NEXT[theme]}`)});
return (
<button type="button" className="icon-button" onClick={cycle} aria-label={label} title={label}>
<Icon name={ICONS[theme]} />
</button>
);
}
+32 -23
View File
@@ -1,10 +1,18 @@
import {getTranslations} from 'next-intl/server';
import {getLocale, getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import type {TimelineBucket} from '@/lib/api';
import {formatMonth, formatNumber} from '@/lib/format';
import {Field, FilterBar} from './FilterBar';
/** Bar chart of claims per month; each bar links to that month (search-in-time). */
export async function Timeline({buckets, basePath, query}: {buckets: TimelineBucket[]; basePath: string; query: Record<string, string>}) {
/** Bar chart of claims per month; each bar links to that month (search in time). */
export async function Timeline({buckets, basePath, query, since}: {
buckets: TimelineBucket[];
basePath: string;
query: Record<string, string>;
since?: string;
}) {
const t = await getTranslations('Timeline');
const locale = await getLocale();
const months = new Map<string, number>();
for (const b of buckets) months.set(b.month, (months.get(b.month) ?? 0) + b.count);
if (!months.size) return null;
@@ -24,49 +32,50 @@ export async function Timeline({buckets, basePath, query}: {buckets: TimelineBuc
}
const visible = all.slice(-120);
const max = Math.max(...visible.map((k) => months.get(k) ?? 0), 1);
const selected = since?.slice(0, 7);
return (
<figure className="timeline">
<figure className="histogram">
<figcaption className="small muted">{t('histogram')} – {t('hint')}</figcaption>
<div className="timeline-bars">
<div className="histogram-bars">
{visible.map((month) => {
const count = months.get(month) ?? 0;
const [yy, mm] = month.split('-').map(Number);
const last = new Date(Date.UTC(yy, mm, 0)).getUTCDate();
const label = `${formatMonth(month, locale)}: ${formatNumber(count, locale)}`;
return (
<Link
key={month}
href={{pathname: basePath, query: {...query, since: `${month}-01`, until: `${month}-${last}`}}}
className="timeline-bar"
title={`${month}: ${count}`}
className={month === selected ? 'histogram-bar is-selected' : 'histogram-bar'}
title={label}
aria-label={label}
>
<span style={{height: `${Math.max((count / max) * 100, count ? 4 : 0)}%`}} />
</Link>
);
})}
</div>
<div className="timeline-axis small muted">
<span>{visible[0]}</span>
<span>{visible[visible.length - 1]}</span>
<div className="histogram-axis">
<span>{formatMonth(visible[0], locale, 'short')}</span>
<span>{formatMonth(visible[visible.length - 1], locale, 'short')}</span>
</div>
</figure>
);
}
export async function DateRangeForm({since, until, hidden}: {since?: string; until?: string; hidden: Record<string, string>}) {
/** From/to filter for lists of claims; keeps the other query parameters (e.g. the tab). */
export async function DateRangeForm({since, until, hidden, resetHref}: {
since?: string;
until?: string;
hidden: Record<string, string>;
resetHref?: string;
}) {
const t = await getTranslations('Timeline');
return (
<form className="filters" method="get">
{Object.entries(hidden).map(([k, v]) => (
<input key={k} type="hidden" name={k} value={v} />
))}
<label>
{t('from')} <input type="date" name="since" defaultValue={since} />
</label>
<label>
{t('to')} <input type="date" name="until" defaultValue={until} />
</label>
<button type="submit">{t('apply')}</button>
</form>
<FilterBar id="dates" active={[since, until].filter(Boolean).length} resetHref={resetHref} hidden={hidden}>
<Field label={t('from')}><input type="date" name="since" defaultValue={since} /></Field>
<Field label={t('to')}><input type="date" name="until" defaultValue={until} /></Field>
</FilterBar>
);
}
+77
View File
@@ -0,0 +1,77 @@
import {getLocale, getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import type {VoteRow} from '@/lib/api';
import {formatDate} from '@/lib/format';
import {PositionChip, ResultBadge} from './badges';
import {ExternalLink} from './ExternalLink';
import {Icon} from './Icon';
import {EmptyState} from './Notice';
/**
* Votes as rows: date, item, result, and either every faction's position (vote items) or one person's position
* (with own vote vs. faction vote, dissent and say-vs-vote). See docs/design/README.md §6.6.
*/
export async function VoteList({votes, compact = false}: {votes: VoteRow[]; compact?: boolean}) {
const t = await getTranslations('Votes');
const locale = await getLocale();
if (!votes.length) return <EmptyState icon="vote">{t('none')}</EmptyState>;
return (
<ul className={compact ? 'vote-list vote-list--compact' : 'vote-list'}>
{votes.map((vote) => {
const personal = 'position' in vote ? vote : null;
const positions = 'positions' in vote ? vote.positions : null;
return (
<li key={`${vote.id}-${personal ? `${personal.individual ? 'i' : 'f'}-${personal.position}` : 'v'}`} className="vote-row">
<div className="vote-date">{vote.voted_on && <time dateTime={vote.voted_on}>{formatDate(vote.voted_on, locale)}</time>}</div>
<div className="vote-body">
<p className="vote-title"><Link href={`/abstimmungen/${vote.id}`}>{vote.item_title}</Link></p>
{!compact && <p className="vote-meta">
<span>{vote.chamber}</span>
{vote.item_ref && <span className="mono">{vote.item_ref}</span>}
{vote.stage && <span>{vote.stage}</span>}
{vote.roll_call && <span className="strong">{t('rollCall')}</span>}
{vote.url && <span><ExternalLink href={vote.url} icon={false}>{t('item')}</ExternalLink></span>}
{vote.protocol_url && <span><ExternalLink href={vote.protocol_url} icon={false}>{t('protocol')}</ExternalLink></span>}
</p>}
{personal && (
<div className="vote-positions">
<PositionChip position={personal.position} />
<span className="muted small">
{personal.individual ? t('ownVote') : t('factionVoteOf', {faction: personal.faction ?? '–'})}
</span>
{personal.dissent && <span className="badge badge--caution"><Icon name="alert" /> {t('dissent')}</span>}
</div>
)}
{!compact && positions && positions.length > 0 && (
<div className="vote-positions">
{positions.map((p) => (
<PositionChip key={p.slug} position={p.position} party={p.party} color={p.color} showLabel={false} />
))}
</div>
)}
{!compact && positions && positions.length === 0 && !vote.roll_call && <p className="muted small">{t('splitVote')}</p>}
{personal?.spoke && (
<p className="vote-say">
<Icon name="mic" /> {t('spoke')}:{' '}
<ExternalLink href={personal.spoke.url}>{personal.spoke.title ?? t('speech')}</ExternalLink>
</p>
)}
</div>
<div className="vote-result"><ResultBadge result={vote.result} /></div>
</li>
);
})}
</ul>
);
}
/** Explains the position chips (shown above lists that use chips without labels). */
export async function VoteLegend() {
const t = await getTranslations('Votes');
return (
<p className="cluster small muted">
{t('legend')}:
{['for', 'against', 'abstain', 'absent'].map((position) => <PositionChip key={position} position={position} />)}
</p>
);
}
-71
View File
@@ -1,71 +0,0 @@
import {getLocale, getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import type {VoteRow} from '@/lib/api';
import {formatDate} from '@/lib/format';
export async function VoteTable({votes}: {votes: VoteRow[]}) {
const t = await getTranslations('Votes');
const locale = await getLocale();
return (
<table className="votes">
<tbody>
{votes.map((vote) => {
const personal = 'position' in vote ? vote : null;
const positions = 'positions' in vote ? vote.positions : null;
return (
<tr key={`${vote.id}-${personal ? `${personal.individual ? 'i' : 'f'}-${personal.position}` : 'v'}`}>
<td className="nowrap">{formatDate(vote.voted_on, locale)}</td>
<td>
<Link href={`/abstimmungen/${vote.id}`}>{vote.item_title}</Link>
<div className="muted small">
{vote.chamber}
{vote.item_ref && <> · {vote.item_ref}</>}
{vote.stage && <> · {vote.stage}</>}
{vote.roll_call && <> · <strong>{t('rollCall')}</strong></>}
{vote.url && (
<> · <a href={vote.url} target="_blank" rel="noopener noreferrer">{t('item')}</a></>
)}
{vote.protocol_url && (
<> · <a href={vote.protocol_url} target="_blank" rel="noopener noreferrer">{t('protocol')}</a></>
)}
</div>
{personal?.spoke && (
<div className="small say-vote">
{t('spoke')}:{' '}
<a href={personal.spoke.url} target="_blank" rel="noopener noreferrer">{personal.spoke.title ?? t('speech')}</a>
</div>
)}
</td>
<td>
{personal && (
<span className={`pos pos-${personal.position}`}>
{t(personal.position as 'for')}
<span className="muted small">
{' '}({personal.individual ? t('ownVote') : `${t('factionVote')} ${personal.faction ?? ''}`})
</span>
</span>
)}
{personal?.dissent && <span className="dissent">{t('dissent')}</span>}
{positions && positions.length > 0 && (
<div className="positions">
{positions.map((p) => (
<span key={p.slug} className={`pos pos-${p.position}`} title={t(p.position as 'for')}>
{p.party}
</span>
))}
</div>
)}
{positions && positions.length === 0 && !vote.roll_call && (
<span className="muted small">{t('splitVote')}</span>
)}
</td>
<td className={`result result-${vote.result}`}>
{vote.result && vote.result !== 'unknown' && t(vote.result as 'accepted')}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
+146
View File
@@ -0,0 +1,146 @@
// Small labels with fixed meanings. Colour never carries meaning alone: every badge has a word, and an icon
// where it helps (see docs/design/README.md §6.3 for the vocabulary).
import {getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/navigation';
import type {ClaimType, PersonStatus} from '@/lib/api';
import {partyStyle} from '@/lib/format';
import {Icon, type IconName} from './Icon';
const TYPE_ICONS: Partial<Record<ClaimType, IconName>> = {
role: 'landmark',
vote: 'vote',
donation: 'euro',
money_flow: 'euro',
party_finance: 'pie',
statement: 'mic',
appearance: 'calendar',
meeting: 'people',
travel: 'plane',
side_job: 'briefcase',
career: 'briefcase',
legal_proceeding: 'scale',
lobbying: 'people',
media_mention: 'news',
parliamentary_question: 'question',
government_decision: 'landmark',
wiki_edit: 'pencil',
declaration: 'document',
ownership: 'link',
affiliation: 'link'
};
export async function TypeLabel({type}: {type: ClaimType}) {
const t = await getTranslations('Claims');
return (
<span className="type-label">
<Icon name={TYPE_ICONS[type] ?? 'document'} />
{t(`type.${type}` as 'type.other')}
</span>
);
}
/** Up to three dots for the number of independent sources, the number written out, a triangle if disputed. */
export async function SourceMeter({count, status}: {count: number; status: string}) {
const t = await getTranslations('Claims');
if (status === 'disputed') {
return (
<span className="source-meter is-disputed">
<Icon name="alert" /> {t('status.disputed')}
</span>
);
}
const title = status === 'corroborated' ? t('corroboratedBy', {count}) : t(`status.${status}` as 'status.reported');
return (
<span className="source-meter" title={title}>
<span className="source-dots" aria-hidden="true">
{[0, 1, 2].map((i) => <span key={i} className={i < count ? 'is-on' : undefined} />)}
</span>
{t('sources', {count})}
</span>
);
}
export function PartyTag({short, slug, color, pill = false, link = true}: {
short: string | null | undefined;
slug?: string | null;
color?: string | null;
pill?: boolean;
link?: boolean;
}) {
if (!short && !slug) return null;
const content = (
<>
<span className="party-dot" style={partyStyle(color)} aria-hidden="true" />
{short ?? slug}
</>
);
const className = pill ? 'party-tag party-tag--pill' : 'party-tag';
return slug && link ? (
<Link href={`/parteien/${slug}`} className={className}>{content}</Link>
) : (
<span className={className}>{content}</span>
);
}
export async function StatusBadge({status, verified = true}: {status: PersonStatus; verified?: boolean}) {
const t = await getTranslations('Persons');
const tone = status === 'active' ? (verified ? 'badge--positive' : 'badge--dashed') : status === 'deceased' ? 'badge--outline' : '';
return (
<span className={`badge badge--lg ${tone}`} title={status === 'active' && !verified ? t('unverifiedStatus') : undefined}>
{status === 'active' && verified && <Icon name="check" />}
{t(`statusValue.${status}`)}
</span>
);
}
const POSITION_ICONS: Record<string, IconName> = {for: 'check', against: 'close'};
/** A vote position, optionally for a party: "● ÖVP ✓ dafür". */
export async function PositionChip({position, party, color, showLabel = true}: {
position: string;
party?: string | null;
color?: string | null;
showLabel?: boolean;
}) {
const t = await getTranslations('Votes');
const known = ['for', 'against', 'abstain', 'absent'].includes(position);
const label = known ? t(position as 'for') : position;
return (
<span className={`position position--${known ? position : 'other'}`} title={party ? `${party}: ${label}` : undefined}>
{party && <span className="party-dot" style={partyStyle(color)} aria-hidden="true" />}
{party}
{POSITION_ICONS[position] ? <Icon name={POSITION_ICONS[position]} /> : <span aria-hidden="true">{position === 'absent' ? '○' : '–'}</span>}
{showLabel ? label : <span className="visually-hidden">{label}</span>}
</span>
);
}
export async function ResultBadge({result, large = false}: {result: string | null; large?: boolean}) {
if (result !== 'accepted' && result !== 'rejected') return null;
const t = await getTranslations('Votes');
return (
<span className={`badge ${large ? 'badge--lg ' : ''}${result === 'accepted' ? 'badge--positive' : 'badge--negative'}`}>
<Icon name={result === 'accepted' ? 'check' : 'close'} />
{t(result)}
</span>
);
}
const PLATFORM_NAMES: Record<string, string> = {
bluesky: 'Bluesky', mastodon: 'Mastodon', telegram: 'Telegram', youtube: 'YouTube', x: 'X', twitter: 'X',
facebook: 'Facebook', instagram: 'Instagram', tiktok: 'TikTok', website: 'Web'
};
export function platformName(platform: string | null | undefined): string {
return platform ? PLATFORM_NAMES[platform] ?? platform : '';
}
export function PlatformBadge({platform}: {platform: string | null | undefined}) {
if (!platform) return null;
return (
<span className={`badge platform-badge platform-${platform}`}>
<span className="party-dot" aria-hidden="true" />
{platformName(platform)}
</span>
);
}
+61
View File
@@ -0,0 +1,61 @@
// Key figures and the three chart types, in plain HTML/CSS (no chart library): bar list, proportion bar,
// (histogram: Timeline.tsx, role timeline: RoleTimeline.tsx). Every chart has its numbers as text.
import type {CSSProperties, ReactNode} from 'react';
import {Link} from '@/i18n/navigation';
import {barStyle} from '@/lib/format';
export type Stat = {label: ReactNode; value: ReactNode; href?: string};
export function StatGrid({items, compact = false}: {items: Stat[]; compact?: boolean}) {
return (
<dl className={compact ? 'stat-grid stat-grid--compact' : 'stat-grid'}>
{items.map((item, i) => (
<div key={i} className="stat">
<dt>{item.label}</dt>
<dd>{item.href ? <Link href={item.href}>{item.value}</Link> : item.value}</dd>
</div>
))}
</dl>
);
}
export type BarRow = {key: string; label: ReactNode; value: number; display: string; meta?: ReactNode};
/** Ranked list with a bar per row, scaled to the largest value in the list. */
export function BarList({rows, label}: {rows: BarRow[]; label?: string}) {
const max = Math.max(...rows.map((r) => r.value), 0);
return (
<ol className="bar-list" aria-label={label}>
{rows.map((row) => (
<li key={row.key}>
<span className="bar-label">{row.label}</span>
<span className="bar-value">{row.display}</span>
{row.meta && <span className="bar-meta">{row.meta}</span>}
<span className="bar" aria-hidden="true"><span style={barStyle(row.value, max)} /></span>
</li>
))}
</ol>
);
}
export type Segment = {key: string; label: string; value: number; className: string};
/** Shares of a whole (vote positions): one bar plus a legend with the counts. */
export function ProportionBar({segments, label}: {segments: Segment[]; label: string}) {
const visible = segments.filter((s) => s.value > 0);
if (!visible.length) return null;
return (
<figure className="proportion">
<div className="proportion-bar" role="img" aria-label={`${label}: ${visible.map((s) => `${s.label} ${s.value}`).join(', ')}`}>
{visible.map((s) => (
<span key={s.key} className={s.className} style={{'--share': s.value} as CSSProperties} title={`${s.label}: ${s.value}`} />
))}
</div>
<ul className="legend" aria-hidden="true">
{visible.map((s) => (
<li key={s.key}><span className={`swatch ${s.className}`} /> {s.label} <strong>{s.value}</strong></li>
))}
</ul>
</figure>
);
}
+30
View File
@@ -0,0 +1,30 @@
// Site structure, shared by the header, the mobile menu and the footer (docs/design/README.md §5).
// `key` is the label in messages/*.json under "Nav".
import type {IconName} from './Icon';
export type NavItem = {href: string; key: string; icon: IconName};
const ITEMS = {
persons: {href: '/personen', key: 'persons', icon: 'person'},
parties: {href: '/parteien', key: 'parties', icon: 'flag'},
votes: {href: '/abstimmungen', key: 'votes', icon: 'vote'},
money: {href: '/geld', key: 'money', icon: 'euro'},
organizations: {href: '/organisationen', key: 'organizations', icon: 'building'},
calendar: {href: '/kalender', key: 'calendar', icon: 'calendar'},
social: {href: '/social', key: 'social', icon: 'message'},
wikipedia: {href: '/wikipedia', key: 'wikipedia', icon: 'pencil'},
former: {href: '/ehemalige', key: 'former', icon: 'history'},
method: {href: '/methodik', key: 'method', icon: 'book'},
settings: {href: '/einstellungen', key: 'settings', icon: 'sliders'},
search: {href: '/suche', key: 'search', icon: 'search'}
} satisfies Record<string, NavItem>;
export const PRIMARY_NAV: NavItem[] = [ITEMS.persons, ITEMS.parties, ITEMS.votes, ITEMS.money, ITEMS.organizations, ITEMS.calendar];
export const MORE_NAV: NavItem[] = [ITEMS.social, ITEMS.wikipedia, ITEMS.former, ITEMS.method];
export const NAV_GROUPS: {key: string; items: NavItem[]}[] = [
{key: 'people', items: [ITEMS.persons, ITEMS.parties, ITEMS.former]},
{key: 'decisions', items: [ITEMS.votes, ITEMS.calendar, ITEMS.social]},
{key: 'influence', items: [ITEMS.money, ITEMS.organizations, ITEMS.wikipedia]},
{key: 'about', items: [ITEMS.method, ITEMS.settings, ITEMS.search]}
];
+1
View File
@@ -500,6 +500,7 @@ export type WikiEdit = {
export type Paths = {
'/api/v1/calendar': CalendarData;
'/api/v1/claims': Claim[];
'/api/v1/claims/{claim_id}': Claim;
'/api/v1/former': FormerPerson[];
'/api/v1/money': MoneyFlow[];
'/api/v1/money/summary': MoneySummaryRow[];
+2
View File
@@ -87,6 +87,8 @@ export const api = {
vote: (id: string) => get<T.VoteDetail>(`/api/v1/votes/${enc(id)}`),
claims: async (params: Params) => get<T.Claim[]>('/api/v1/claims', await withPrefs(params)),
/** A single claim for its permalink page - deliberately without the visitor's filters (same page for everyone). */
claim: (id: string | number) => get<T.Claim>(`/api/v1/claims/${enc(String(id))}`),
timeline: async (params: Params) => get<T.TimelineBucket[]>('/api/v1/timeline', await withPrefs(params, false)),
calendar: async (params: Params) => get<T.CalendarData>('/api/v1/calendar', await withPrefs(params, false)),
+57 -2
View File
@@ -1,14 +1,69 @@
import type {CSSProperties} from 'react';
export function intlLocale(locale: string): string {
return locale === 'de' ? 'de-AT' : 'en-GB';
}
export function formatDate(value: string | null | undefined, locale: string): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString(locale === 'de' ? 'de-AT' : 'en-GB', {year: 'numeric', month: 'short', day: 'numeric'});
return date.toLocaleDateString(intlLocale(locale), {year: 'numeric', month: 'short', day: 'numeric'});
}
/** Reporting periods as published ("2025-H2", "2025-Q1", "2025") in words. */
export function formatPeriod(period: string | null | undefined, locale: string): string {
if (!period) return '';
const m = /^(\d{4})-([HQ])([1-4])$/.exec(period);
if (!m) return period;
const [, year, unit, n] = m;
if (locale === 'de') return `${n}. ${unit === 'H' ? 'Halbjahr' : 'Quartal'} ${year}`;
return `${unit}${n} ${year}`;
}
/** "2026-03" -> "März 2026" / "March 2026" */
export function formatMonth(month: string, locale: string, style: 'long' | 'short' = 'long'): string {
const [y, m] = month.split('-').map(Number);
if (!y || !m) return month;
return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString(intlLocale(locale), {month: style, year: 'numeric', timeZone: 'UTC'});
}
export function formatNumber(value: number | null | undefined, locale: string): string {
return value === null || value === undefined ? '' : value.toLocaleString(intlLocale(locale));
}
/** Exact amount as reported: cents only when the source has them ("25.000 €", "1.234,56 €"). */
export function formatMoney(value: string | number, locale: string): string {
return Number(value).toLocaleString(locale === 'de' ? 'de-AT' : 'en-GB', {style: 'currency', currency: 'EUR'});
const n = Number(value);
const digits = Number.isInteger(n) ? 0 : 2;
return n.toLocaleString(intlLocale(locale), {style: 'currency', currency: 'EUR', minimumFractionDigits: digits, maximumFractionDigits: digits});
}
/** Compact amounts for summaries and bar lists ("1,2 Mio. €"); tables use formatMoney. */
export function formatMoneyCompact(value: string | number, locale: string): string {
const n = Number(value);
if (Math.abs(n) < 1_000_000) return formatMoney(Math.round(n), locale);
return n.toLocaleString(intlLocale(locale), {
style: 'currency', currency: 'EUR', notation: 'compact', compactDisplay: 'short', maximumFractionDigits: 1
});
}
export function localized<T extends {name_de: string; name_en: string}>(item: T, locale: string): string {
return locale === 'en' ? item.name_en || item.name_de : item.name_de;
}
/** "Maria Muster-Beispiel" -> "MM" (avatar placeholder) */
export function initials(name: string): string {
const parts = name.replace(/\b(Dr|Mag|Ing|DI|MBA|BA|MA|MSc|BSc|Prof|Dipl)\.?\b/g, '').split(/[\s-]+/).filter(Boolean);
return ((parts[0]?.[0] ?? '') + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase();
}
/** Party colour as a CSS variable for dots and bars (never for text or large areas). */
export function partyStyle(color: string | null | undefined): CSSProperties | undefined {
return color ? ({'--party': color} as CSSProperties) : undefined;
}
/** Share of a maximum as a CSS variable for bars. */
export function barStyle(value: number, max: number): CSSProperties {
return {'--value': `${max > 0 ? Math.max((value / max) * 100, 0.5) : 0}%`} as CSSProperties;
}
+12
View File
@@ -26,3 +26,15 @@ export function parsePrefs(raw: string | undefined): Prefs {
export function serializePrefs(prefs: Prefs): string {
return encodeURIComponent(JSON.stringify(prefs));
}
export function activeFilterCount(prefs: Prefs): number {
return prefs.excludedSources.length + prefs.topics.length + (prefs.minTrust > 0 ? 1 : 0);
}
// Colour theme: absent cookie = follow the system setting.
export const THEME_COOKIE = 'pt_theme';
export type Theme = 'system' | 'light' | 'dark';
export function parseTheme(raw: string | undefined): Theme {
return raw === 'light' || raw === 'dark' ? raw : 'system';
}
+14
View File
@@ -0,0 +1,14 @@
import type {Role} from './api';
type RoleLike = Pick<Role, 'role' | 'body' | 'party_short'>;
/** Display name of a role: parliament mandates show the chamber, memberships the party, others their title. */
export function roleLabel(role: RoleLike): string {
if (role.role === 'mp') return role.body ?? role.role;
if (role.role === 'party_member') return role.party_short ?? role.body ?? role.role;
return role.role;
}
export function isCurrent(role: Pick<Role, 'end_date'>, now = new Date()): boolean {
return !role.end_date || new Date(role.end_date) >= now;
}
+250
View File
@@ -0,0 +1,250 @@
/* Base: element defaults, typography, forms. No classes here except a few text utilities at the end. */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--paper);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading);
overflow-wrap: break-word;
}
/* German compounds: hyphenate running text so long words never break the layout on phones */
p, li, dd, td, th, h1, h2, h3, h4, blockquote, figcaption, label, legend {
hyphens: auto;
}
h1, h2, h3, h4 {
margin: 0 0 var(--space-3);
line-height: var(--leading-tight);
letter-spacing: -0.01em;
font-weight: 700;
text-wrap: balance;
}
h1 { font-size: var(--text-2xl); }
h2 { font-size: var(--text-lg); margin-top: var(--space-6); }
h3 { font-size: var(--text-md); margin-top: var(--space-5); }
h4 { font-size: var(--text-base); }
p, ul, ol, dl, blockquote, figure, table {
margin: 0 0 var(--space-4);
}
ul, ol { padding-left: var(--space-5); }
a {
color: var(--accent);
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
transition: color var(--transition), text-decoration-color var(--transition);
}
a:hover {
color: var(--accent-hover);
text-decoration-color: currentColor;
}
:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
border-radius: 2px;
}
img, svg {
max-width: 100%;
}
img {
height: auto;
}
hr {
border: 0;
border-top: 1px solid var(--line);
margin: var(--space-6) 0;
}
blockquote {
margin-left: 0;
padding-left: var(--space-4);
border-left: 3px solid var(--line-strong);
color: var(--ink-2);
}
mark {
background: var(--mark);
color: var(--mark-ink);
padding: 0 0.1em;
border-radius: 2px;
}
code, kbd, .mono {
font-family: var(--font-mono);
font-size: 0.92em;
}
abbr[title] {
text-decoration: underline dotted;
cursor: help;
}
time {
white-space: nowrap;
}
/* tables */
table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
th, td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
font-size: var(--text-sm);
font-weight: 600;
color: var(--ink-2);
border-bottom-color: var(--line-strong);
white-space: nowrap;
}
tbody tr:hover {
background: color-mix(in srgb, var(--surface-2) 60%, transparent);
}
/* forms */
input, select, textarea, button {
font: inherit;
color: inherit;
}
input:not([type='checkbox'], [type='radio'], [type='range']), select, textarea {
min-height: 2.5rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--line-field);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
width: 100%;
}
input::placeholder {
color: var(--ink-3);
}
input[type='checkbox'], input[type='radio'], input[type='range'] {
accent-color: var(--accent);
}
input[type='checkbox'], input[type='radio'] {
width: 1.1rem;
height: 1.1rem;
margin: 0;
vertical-align: -0.15em;
}
label {
color: var(--ink-2);
}
button, .button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 2.5rem;
padding: var(--space-2) var(--space-4);
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
line-height: 1.2;
text-decoration: none;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition);
}
button:hover, .button:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: var(--on-accent);
}
button.secondary, .button.secondary {
background: var(--surface);
border-color: var(--line-strong);
color: var(--ink);
}
button.secondary:hover, .button.secondary:hover {
background: var(--surface-2);
color: var(--ink);
}
button.ghost, .button.ghost {
background: transparent;
border-color: transparent;
color: var(--ink-2);
}
button.ghost:hover, .button.ghost:hover {
background: var(--surface-2);
color: var(--ink);
}
details > summary {
cursor: pointer;
}
/* text utilities */
.muted { color: var(--ink-3); }
.small { font-size: var(--text-sm); }
.xsmall { font-size: var(--text-xs); }
.strong { font-weight: 600; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.nowrap { white-space: nowrap; }
.block { display: block; }
.visually-hidden {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition: none !important;
animation: none !important;
scroll-behavior: auto !important;
}
}
File diff suppressed because it is too large. Load diff
+464
View File
@@ -0,0 +1,464 @@
/* Page frame (header, main, footer) and layout utilities. Breakpoints: 640 / 1024 / 1280 px. */
.skip-link {
position: absolute;
left: var(--space-4);
top: -3rem;
z-index: 100;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius);
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
}
.skip-link:focus {
top: var(--space-2);
color: var(--on-accent);
}
.page {
flex: 1;
width: 100%;
max-width: var(--page-max);
margin: 0 auto;
padding: var(--space-5) var(--gutter) var(--space-8);
}
/* ---------- site header ---------- */
.site-header {
position: sticky;
top: 0;
z-index: 30;
background: color-mix(in srgb, var(--surface) 94%, transparent);
border-bottom: 1px solid var(--line);
backdrop-filter: saturate(1.4) blur(8px);
}
.site-header-inner {
display: flex;
align-items: center;
gap: var(--space-4);
max-width: var(--page-max);
height: var(--header-h);
margin: 0 auto;
padding: 0 var(--gutter);
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--ink);
font-size: 1.25rem;
font-weight: 800;
letter-spacing: -0.03em;
text-decoration: none;
white-space: nowrap;
}
.brand:hover {
color: var(--ink);
}
.brand-mark {
width: 1.75rem;
height: 1.75rem;
color: var(--accent);
flex-shrink: 0;
}
.main-nav > ul {
display: flex;
align-items: center;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.nav-link, .nav-more > summary {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
color: var(--ink-2);
font-size: 0.95rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
list-style: none;
}
.nav-more > summary::-webkit-details-marker, .menu > summary::-webkit-details-marker {
display: none;
}
.nav-link:hover, .nav-more > summary:hover {
background: var(--surface-2);
color: var(--ink);
}
.nav-link[aria-current='page'] {
color: var(--ink);
font-weight: 650;
box-shadow: inset 0 -2px 0 var(--accent);
border-radius: var(--radius) var(--radius) 0 0;
}
.nav-more {
position: relative;
}
.nav-more[open] > summary {
background: var(--surface-2);
}
.nav-more-panel, .menu-panel {
position: absolute;
top: calc(100% + var(--space-2));
z-index: 40;
margin: 0;
padding: var(--space-2);
list-style: none;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.nav-more-panel {
left: 0;
min-width: 15rem;
}
.nav-more-panel .nav-link {
display: flex;
white-space: normal;
}
.header-tools {
display: flex;
align-items: center;
gap: var(--space-1);
margin-left: auto;
}
.header-search {
position: relative;
width: 13rem;
}
.header-search input {
min-height: 2.25rem;
padding-right: 2.25rem;
border-color: var(--line);
background: var(--paper);
font-size: var(--text-sm);
}
.header-search button {
position: absolute;
right: 0;
top: 0;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
min-width: 2.25rem;
min-height: 2.25rem;
padding: var(--space-1) var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--ink-2);
font-size: var(--text-sm);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
list-style: none;
cursor: pointer;
}
.icon-button:hover {
background: var(--surface-2);
border-color: transparent;
color: var(--ink);
}
.icon-button .icon {
width: 1.25rem;
height: 1.25rem;
}
.count-badge {
display: inline-grid;
place-items: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.3rem;
border-radius: var(--radius-pill);
background: var(--accent);
color: var(--on-accent);
font-size: var(--text-xs);
font-weight: 700;
line-height: 1;
}
.menu {
position: relative;
display: none;
}
.menu-panel {
right: 0;
width: min(22rem, calc(100vw - 2 * var(--gutter)));
max-height: calc(100vh - var(--header-h) - var(--space-4));
overflow: auto;
padding: var(--space-4);
}
.menu-group + .menu-group {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--line);
}
.menu-group-title {
margin: 0 0 var(--space-2);
color: var(--ink-3);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.menu-group ul {
margin: 0;
padding: 0;
list-style: none;
}
.menu-group .nav-link {
display: flex;
padding: var(--space-2);
}
@media (max-width: 1023px) {
.main-nav, .header-search, .tool-label {
display: none;
}
.menu {
display: block;
}
}
@media (min-width: 1024px) {
.header-search-link {
display: none;
}
}
@media (min-width: 1024px) and (max-width: 1359px) {
.tool-label {
display: none;
}
}
@media (min-width: 1024px) and (max-width: 1180px) {
.header-search {
width: 10rem;
}
}
/* ---------- site footer ---------- */
.site-footer {
margin-top: var(--space-8);
border-top: 1px solid var(--line);
background: var(--surface);
color: var(--ink-2);
font-size: var(--text-sm);
}
.site-footer-inner {
display: grid;
gap: var(--space-6);
max-width: var(--page-max);
margin: 0 auto;
padding: var(--space-7) var(--gutter) var(--space-6);
}
@media (min-width: 1024px) {
.site-footer-inner {
grid-template-columns: 1.3fr 3fr;
}
}
.footer-brand p {
max-width: 24rem;
}
.footer-principles {
margin: var(--space-4) 0 0;
padding: 0;
list-style: none;
}
.footer-principles li {
display: flex;
gap: var(--space-2);
align-items: flex-start;
margin-bottom: var(--space-2);
}
.footer-principles .icon {
flex-shrink: 0;
margin-top: 0.15rem;
color: var(--accent);
}
.footer-nav {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
.footer-nav h2 {
margin: 0 0 var(--space-3);
color: var(--ink);
font-size: var(--text-sm);
}
.footer-nav ul {
margin: 0;
padding: 0;
list-style: none;
}
.footer-nav li {
margin-bottom: var(--space-2);
}
.footer-nav a {
color: var(--ink-2);
text-decoration: none;
}
.footer-nav a:hover {
color: var(--accent);
text-decoration: underline;
}
.footer-bottom {
max-width: var(--page-max);
margin: 0 auto;
padding: 0 var(--gutter) var(--space-6);
color: var(--ink-3);
font-size: var(--text-xs);
}
.footer-bottom > span {
display: block;
padding-top: var(--space-4);
border-top: 1px solid var(--line);
}
/* ---------- layout utilities ---------- */
.stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.cluster {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.grid-2 {
display: grid;
gap: var(--space-5) var(--space-6);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
align-items: start;
}
.grid-cards {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--card-min, 17rem)), 1fr));
margin: 0 0 var(--space-5);
padding: 0;
list-style: none;
}
.layout-aside {
display: grid;
gap: var(--space-6);
align-items: start;
}
@media (min-width: 1024px) {
.layout-aside {
grid-template-columns: minmax(0, 1fr) 20rem;
}
}
.layout-aside > * {
min-width: 0;
}
.section {
margin-top: var(--space-7);
}
.section:first-child {
margin-top: 0;
}
.section-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2) var(--space-4);
margin-bottom: var(--space-3);
}
.section-head h2 {
margin: 0;
}
.section-head h2 + .muted {
margin: 0;
}
.more-link {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-sm);
font-weight: 600;
text-decoration: none;
}
.more-link:hover {
text-decoration: underline;
}
.prose {
max-width: var(--prose-max);
}
.prose h2 {
margin-top: var(--space-7);
}
+34
View File
@@ -0,0 +1,34 @@
/* Print: content only, sources expanded, external URLs written out. */
@media print {
:root {
--paper: #fff;
--surface: #fff;
}
.site-header, .site-footer, .skip-link, .filter-bar, .pagination, .tabs, .search, .sticky-actions,
.permalink, .header-tools {
display: none !important;
}
.page {
max-width: none;
padding: 0;
}
.claim, .post, .panel, .card, .stat, .table-wrap {
break-inside: avoid;
box-shadow: none;
}
/* <details> can't be opened from CSS everywhere; at least keep the summary and show link targets */
a[href^='http']::after {
content: ' <' attr(href) '>';
color: #555;
font-size: 0.8em;
overflow-wrap: anywhere;
}
.cite::after, .ext-link::after {
content: none !important;
}
}
+142
View File
@@ -0,0 +1,142 @@
/*
* Design tokens - see docs/design/design-system.md. Light theme on :root; the dark theme applies with the
* system preference unless the visitor picked a theme (data-theme on <html>, set by ThemeSwitcher).
* Contrast of every text/background pair is checked against WCAG AA in the design system doc.
*/
:root {
color-scheme: light;
/* neutrals: paper and ink */
--paper: #f6f5f1;
--surface: #ffffff;
--surface-2: #efede7;
--surface-3: #e4e1d9;
--line: #dcd8cf;
--line-strong: #b9b3a6;
--line-field: #878073; /* form field borders: >= 3:1 against surfaces (WCAG 1.4.11) */
--ink: #1c1b18;
--ink-2: #47443d;
--ink-3: #66625a;
/* accent: ink indigo - a colour no Austrian party uses */
--accent: #4338ca;
--accent-hover: #3730a3;
--accent-soft: #ebeafd;
--accent-ink: #312e81;
--on-accent: #ffffff;
/* highlighter: quoted evidence, search hits */
--mark: #fde68a;
--mark-ink: #1c1b18;
/* semantic - only in small badges, always with an icon or a word */
--positive: #146c43;
--positive-soft: #e2f2e8;
--negative: #b3261e;
--negative-soft: #fbe9e6;
--caution: #8a5300;
--caution-soft: #fcf0d4;
--focus: #4338ca;
/* typography */
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono", monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-md: 1.125rem;
--text-lg: 1.25rem;
--text-xl: 1.5rem;
--text-2xl: clamp(1.625rem, 1.3rem + 1.4vw, 2.125rem);
--text-display: clamp(2rem, 1.3rem + 2.8vw, 3rem);
--leading: 1.55;
--leading-tight: 1.2;
/* spacing (4 px grid) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--space-7: 3rem;
--space-8: 4rem;
/* shape and depth */
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--radius-pill: 999px;
--shadow-sm: 0 1px 2px rgb(28 27 24 / 0.06);
--shadow: 0 6px 24px rgb(28 27 24 / 0.12);
/* layout */
--page-max: 76rem;
--prose-max: 44rem;
--gutter: clamp(1rem, 4vw, 2rem);
--header-h: 3.5rem;
--transition: 120ms ease-out;
}
/* dark theme: the same token names, twice - for the system preference and for an explicit choice */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
color-scheme: dark;
--paper: #131311;
--surface: #1b1a17;
--surface-2: #24231f;
--surface-3: #2e2c27;
--line: #36332d;
--line-strong: #57534a;
--line-field: #7d786f;
--ink: #ecebe6;
--ink-2: #c9c5bb;
--ink-3: #a29d92;
--accent: #a5b4fc;
--accent-hover: #c7d2fe;
--accent-soft: #25264d;
--accent-ink: #c7d2fe;
--on-accent: #16163a;
--mark: #5a4b10;
--mark-ink: #fff6d5;
--positive: #74d3a2;
--positive-soft: #11301f;
--negative: #ff9d90;
--negative-soft: #3c1612;
--caution: #f2c35c;
--caution-soft: #36290c;
--focus: #a5b4fc;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
--shadow: 0 6px 24px rgb(0 0 0 / 0.5);
}
}
:root[data-theme='dark'] {
color-scheme: dark;
--paper: #131311;
--surface: #1b1a17;
--surface-2: #24231f;
--surface-3: #2e2c27;
--line: #36332d;
--line-strong: #57534a;
--line-field: #7d786f;
--ink: #ecebe6;
--ink-2: #c9c5bb;
--ink-3: #a29d92;
--accent: #a5b4fc;
--accent-hover: #c7d2fe;
--accent-soft: #25264d;
--accent-ink: #c7d2fe;
--on-accent: #16163a;
--mark: #5a4b10;
--mark-ink: #fff6d5;
--positive: #74d3a2;
--positive-soft: #11301f;
--negative: #ff9d90;
--negative-soft: #3c1612;
--caution: #f2c35c;
--caution-soft: #36290c;
--focus: #a5b4fc;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
--shadow: 0 6px 24px rgb(0 0 0 / 0.5);
}