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:
1 parent
45514624f4
commit
456f9816f4
76 files changed
+6973
-1126
No files matched your search
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
""",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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. 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)
|
||||
|
||||
@@ -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. 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<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="muted">
|
||||
{formatDate(vote.voted_on, loc)} · {vote.chamber}
|
||||
{vote.item_ref && <> · {vote.item_ref}</>}
|
||||
<p className="page-header-meta">
|
||||
{vote.item_ref && <span className="mono">{vote.item_ref}</span>}
|
||||
{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>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<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 ?? 0) > 20).map((p) => (
|
||||
<option key={p.slug} value={p.slug}>{p.short_name ?? p.name}</option>
|
||||
))}
|
||||
{parties.filter((p) => p.total_people > 20).map((p) => <option key={p.slug} value={p.slug}>{p.short_name ?? p.name}</option>)}
|
||||
</select>
|
||||
<button type="submit">OK</button>
|
||||
</form>
|
||||
</Field>
|
||||
</FilterBar>
|
||||
|
||||
{people.length === 0 ? <EmptyState icon="history">{t('none')}</EmptyState> : (
|
||||
<ul className="claims">
|
||||
{people.map((p) => (
|
||||
<li key={p.slug} className="claim">
|
||||
<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}`}><strong>{p.full_name}</strong></Link>
|
||||
<span>{tp(`statusValue.${p.status}`)}</span>
|
||||
<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, loc)})}</span>}
|
||||
{p.last_office_end && <span>{tp('lastOffice', {date: formatDate(p.last_office_end, locale)})}</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 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="small"><span className="muted">{t('career')}: </span>{p.latest_career}</p>
|
||||
)}
|
||||
{p.latest_career && <p className="claim-detail"><span className="muted">{t('career')}: </span>{p.latest_career}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{people.length === 50 && (
|
||||
<p><Link href={{pathname: '/ehemalige', query: {...sp, page: String(page + 1)}}}>→</Link></p>
|
||||
)}
|
||||
<Pagination pathname="/ehemalige" query={sp} page={page} hasNext={people.length === PAGE} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}>
|
||||
<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>
|
||||
<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>
|
||||
</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 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>
|
||||
)}
|
||||
|
||||
<section className={filtered ? undefined : 'section'} aria-labelledby="flows">
|
||||
<SectionHead id="flows" title={t('flows')} />
|
||||
<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>
|
||||
<Pagination pathname="/geld" query={sp} page={page} hasNext={flows.length === PAGE} />
|
||||
</section>
|
||||
<Notice tone="note">{t('sourcesNote')}</Notice>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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')} />
|
||||
<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="">–</option>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k} value={k}>{t(`kind.${k}`)}</option>
|
||||
))}
|
||||
<option value="">{t('allKinds')}</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>
|
||||
</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.length > 0 && (
|
||||
<ul className="calendar-events">
|
||||
{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}
|
||||
<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 && (
|
||||
<div className="small">
|
||||
{ev.participants.map((p) => (
|
||||
<Link key={p.slug} href={`/personen/${p.slug}`}>{p.name}</Link>
|
||||
))}
|
||||
</div>
|
||||
<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="small muted">+{events.length - 6}</li>}
|
||||
{events.length > 6 && <li className="muted">{t('more', {count: events.length - 6})}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<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>
|
||||
<table>
|
||||
<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></th><th>{t('license')}</th><th>{t('access')}</th><th>{t('trust')}</th></tr>
|
||||
<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>
|
||||
</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 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>
|
||||
<td className="small">{s.license}</td>
|
||||
<td className="small">{s.access_method}</td>
|
||||
<td className="num">{s.default_trust}</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>
|
||||
);
|
||||
}
|
||||
@@ -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,69 +48,77 @@ 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')}
|
||||
<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>
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{receivedFrom.length > 0 && (
|
||||
<section aria-labelledby="received-from">
|
||||
<SectionHead id="received-from" title={t('receivedFromTitle')} />
|
||||
<BarList rows={receivedFrom} label={t('receivedFromTitle')} />
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
<h2>{t('people')}</h2>
|
||||
<p className="notice small">{t('connectionNote')}</p>
|
||||
<ul className="compact">
|
||||
<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>{' '}
|
||||
@@ -111,16 +129,52 @@ export default async function OrganizationPage({params}: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2>{t('flows')}</h2>
|
||||
<MoneyTable flows={flows} />
|
||||
{flows.length === 50 && <p><Link href={`/geld?org=${slug}`}>→ {t('allFlows')}</Link></p>}
|
||||
<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>
|
||||
<h2>{t('claims')}</h2>
|
||||
<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>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
'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>
|
||||
{orgs.length === 0 ? <EmptyState icon="building">{t('none')}</EmptyState> : (
|
||||
<div className="table-wrap">
|
||||
<table className="table-stack">
|
||||
<thead>
|
||||
<tr><th>{t('name')}</th><th>{t('kindLabel')}</th><th className="num">{t('paid')}</th><th className="num">{t('received')}</th></tr>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
{orgs.length === 100 && (
|
||||
<p><Link href={{pathname: '/organisationen', query: {...sp, page: String(page + 1)}}}>→</Link></p>
|
||||
)}
|
||||
<Pagination pathname="/organisationen" query={sp} page={page} hasNext={orgs.length === PAGE} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<h2>{t('latest')}</h2>
|
||||
<ClaimList claims={claims} />
|
||||
<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 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,52 +41,70 @@ 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 ? (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>{t('year')}</th>{kinds.map((kind) => <th key={kind}>{tm(`kind.${kind}` as 'kind.other')}</th>)}</tr>
|
||||
<tr><th scope="col">{t('year')}</th>{kinds.map((kind) => <th key={kind} scope="col" className="num">{tm(`kind.${kind}` as 'kind.other')}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{years.map((year) => (
|
||||
<tr key={year}>
|
||||
<td>{year}</td>
|
||||
<th scope="row">{year}</th>
|
||||
{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 key={kind} className="num" title={row?.total ? `${formatMoney(row.total, locale)} · ${row.count}×` : undefined}>
|
||||
{row?.total ? formatMoneyCompact(row.total, locale) : row ? `${row.count}×` : '–'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
@@ -87,80 +112,93 @@ export default async function PartyPage({params}: Props) {
|
||||
))}
|
||||
</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>
|
||||
</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">
|
||||
<div className="grid-2 section">
|
||||
{party.finance.length > 0 && (
|
||||
<section>
|
||||
<h2>{t('finance')}</h2>
|
||||
<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)}>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</p>
|
||||
{person.birth_date && (
|
||||
<p className="muted">
|
||||
* {formatDate(person.birth_date, locale)}
|
||||
{person.death_date && <> · † {formatDate(person.death_date, locale)}</>}
|
||||
</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 && '*'}
|
||||
</a>
|
||||
))}
|
||||
</p>
|
||||
<span className="muted small">{tp('lastOffice', {date: formatDate(person.last_office_end, locale)})}</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<section>
|
||||
<h2>{t('roles')}</h2>
|
||||
<ul className="roles">
|
||||
{[...currentRoles, ...pastRoles].map((role, i) => (
|
||||
<h1>{person.full_name}</h1>
|
||||
{(offices.length > 0 || memberships.length > 0) && (
|
||||
<ul className="profile-roles">
|
||||
{offices.map((r, 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>
|
||||
<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>
|
||||
<ClaimList claims={await api.claims({person: slug, types: [...TABS.overview], limit: 25})} showSubject={false} />
|
||||
)}
|
||||
{(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>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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' && (
|
||||
<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>
|
||||
))}
|
||||
</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,27 +247,37 @@ 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>
|
||||
<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><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">
|
||||
<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="chip">
|
||||
<span key={key} className="block">
|
||||
{tm(`kind.${key.slice(0, split)}` as 'kind.other')} ({t(key.slice(split + 1) as 'paid')}):{' '}
|
||||
{total ? formatMoney(total, locale) : `${count}×`}
|
||||
<span className="amount">{total ? formatMoneyCompact(total, locale) : `${count}×`}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -204,24 +286,13 @@ async function PersonNetwork({slug, locale}: {slug: string; locale: string}) {
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
{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>
|
||||
{persons.length === 60 && (
|
||||
<p>
|
||||
<Link href={{pathname: '/personen', query: {...sp, page: String(page + 1)}}}>→</Link>
|
||||
</p>
|
||||
)}
|
||||
<Pagination pathname="/personen" query={sp} page={page} hasNext={persons.length === PAGE} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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')}>
|
||||
<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}>{p}</option>)}
|
||||
{PLATFORMS.map((p) => <option key={p} value={p}>{platformName(p)}</option>)}
|
||||
</select>
|
||||
<select name="party" defaultValue={sp.party ?? ''} aria-label={t('party')}>
|
||||
</Field>
|
||||
<Field label={t('party')}>
|
||||
<select name="party" defaultValue={sp.party ?? ''}>
|
||||
<option value="">{t('allParties')}</option>
|
||||
{parties.map((p) => <option key={p.slug} value={p.slug}>{p.short_name ?? p.name}</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>
|
||||
<button type="submit">OK</button>
|
||||
</form>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
<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} className="claim">
|
||||
<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">{hit.summary_de}</p>
|
||||
<p className="claim-summary">{highlight(hit.summary_de, q ?? '')}</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>
|
||||
)}
|
||||
<span className="block small muted">{hit.text.slice(0, 280)}…</span>
|
||||
<p className="claim-summary">
|
||||
<ExternalLink href={hit.document_url}>{hit.document_title ?? hit.document_url}</ExternalLink>
|
||||
</p>
|
||||
)}
|
||||
<ol className="small">
|
||||
<p className="hit-text">{highlight(`${hit.text.slice(0, 280)}…`, q ?? '')}</p>
|
||||
</>
|
||||
)}
|
||||
{(hit.evidence ?? []).length > 0 && (
|
||||
<p className="cite-list">
|
||||
{(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>
|
||||
<a key={ev.url} href={ev.url} className="cite" target="_blank" rel="noopener noreferrer nofollow" title={ev.title ?? undefined}>
|
||||
{ev.source}
|
||||
</a>
|
||||
))}
|
||||
</ol>
|
||||
{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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<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>{t('date')}</th><th>{t('network')}</th><th>{t('article')}</th><th>{t('change')}</th></tr>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{edits.map((edit) => (
|
||||
{edits.map((edit) => {
|
||||
const size = edit.data.sizediff !== undefined && edit.data.sizediff !== null ? Number(edit.data.sizediff) : null;
|
||||
return (
|
||||
<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 ?? '')}
|
||||
<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 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 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 |
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
{claims.map((claim) => (
|
||||
<li key={claim.id}><ClaimCard claim={claim} showSubject={showSubject} /></li>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<div className="table-wrap">
|
||||
<table className="table-stack">
|
||||
<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>
|
||||
<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>
|
||||
</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 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 className="small">
|
||||
<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">
|
||||
[{ev.source}]
|
||||
<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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]}
|
||||
];
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user