migrate mobile components to konsta ui, fix german strings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { Block } from "konsta/react"
|
||||
|
||||
export function FeedEmpty() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground px-6 text-center">
|
||||
<Block className="text-center mt-12">
|
||||
<p className="text-lg font-medium">Dein Feed ist leer</p>
|
||||
<p className="text-sm mt-2">Folge Themen oder Abgeordneten, um Abstimmungen hier zu sehen.</p>
|
||||
</div>
|
||||
<p className="text-sm text-black/55 dark:text-white/55 mt-2">
|
||||
Folge Themen oder Abgeordneten, um Abstimmungen hier zu sehen.
|
||||
</p>
|
||||
</Block>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Chip } from "konsta/react"
|
||||
import type { FeedItem as FeedItemType } from "../lib/assemble-feed"
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
@@ -10,44 +10,40 @@ function formatDate(iso: string | null): string {
|
||||
|
||||
export function FeedItemCard({ item }: { item: FeedItemType }) {
|
||||
return (
|
||||
<article className="p-4">
|
||||
<article className="px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-base font-medium leading-snug hover:underline"
|
||||
className="text-[15px] font-medium leading-snug hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
<h2 className="text-base font-medium leading-snug">{item.title}</h2>
|
||||
<h2 className="text-[15px] font-medium leading-snug">{item.title}</h2>
|
||||
)}
|
||||
{item.date && (
|
||||
<time dateTime={item.date} className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
||||
<time dateTime={item.date} className="text-xs text-black/55 dark:text-white/55 whitespace-nowrap shrink-0">
|
||||
{formatDate(item.date)}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
{item.topics.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2" aria-label="Topics">
|
||||
<div className="flex flex-wrap gap-1 mt-2" aria-label="Themen">
|
||||
{item.topics.map((topic) =>
|
||||
topic.url ? (
|
||||
<a key={topic.label} href={topic.url} target="_blank" rel="noopener noreferrer">
|
||||
<Badge variant="secondary" className="hover:bg-accent-foreground/10 cursor-pointer">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
<Chip className="cursor-pointer">{topic.label}</Chip>
|
||||
</a>
|
||||
) : (
|
||||
<Badge key={topic.label} variant="secondary">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
<Chip key={topic.label}>{topic.label}</Chip>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">{item.source}</p>
|
||||
<p className="text-xs text-black/55 dark:text-white/55 mt-1">{item.source}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BlockTitle, List } from "konsta/react"
|
||||
import { useMemo } from "react"
|
||||
import type { FeedItem } from "../lib/assemble-feed"
|
||||
import { FeedEmpty } from "./feed-empty"
|
||||
@@ -20,26 +21,22 @@ export function FeedList({ items }: { items: FeedItem[] }) {
|
||||
<div>
|
||||
{upcoming.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-4 pt-4 pb-2">
|
||||
Anstehende Abstimmungen
|
||||
</h2>
|
||||
<div className="divide-y divide-border">
|
||||
<BlockTitle>Anstehende Abstimmungen</BlockTitle>
|
||||
<List dividers>
|
||||
{upcoming.map((item) => (
|
||||
<FeedItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
</section>
|
||||
)}
|
||||
{past.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-4 pt-4 pb-2">
|
||||
Vergangene Abstimmungen
|
||||
</h2>
|
||||
<div className="divide-y divide-border">
|
||||
<BlockTitle>Vergangene Abstimmungen</BlockTitle>
|
||||
<List dividers>
|
||||
{past.map((item) => (
|
||||
<FeedItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Block, Button, Card, List, ListItem, Navbar, Searchbar } from "konsta/react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { type GeoResult, loadCachedResult } from "../../location/lib/geo"
|
||||
import { getPartyMeta } from "../../location/lib/parties"
|
||||
@@ -16,7 +14,6 @@ interface PartyGroup {
|
||||
/** Extract party/fraction label for grouping. Prefers party, falls back to current fraction. */
|
||||
function partyLabel(m: MandateWithPolitician): string {
|
||||
if (m.party?.label) return m.party.label
|
||||
// fraction label is e.g. "CDU/CSU (Bundestag 2025 - 2029)" — strip the parliament suffix
|
||||
const current = m.fraction_membership?.find((f) => !f.valid_until)
|
||||
if (current) return current.fraction.label.replace(/\s*\([^)]+\)\s*$/, "")
|
||||
return "parteilos"
|
||||
@@ -74,36 +71,38 @@ export function PoliticianSearch() {
|
||||
|
||||
if (!result || result.mandates.length === 0) {
|
||||
return (
|
||||
<main className="p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
No parliament members loaded yet. Detect your location in Settings first.
|
||||
</p>
|
||||
<Link to="/app/settings" className="text-primary text-sm underline">
|
||||
Zu den Einstellungen
|
||||
</Link>
|
||||
</main>
|
||||
<>
|
||||
<Navbar title="Abgeordnete" />
|
||||
<Block className="text-center mt-12">
|
||||
<p className="text-black/55 dark:text-white/55 text-sm mb-4">
|
||||
Noch keine Abgeordneten geladen. Erkenne zuerst deinen Standort in den Einstellungen.
|
||||
</p>
|
||||
<Link to="/app/settings" className="text-primary text-sm underline">
|
||||
Zu den Einstellungen
|
||||
</Link>
|
||||
</Block>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="p-4 border-b border-border">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter by name…"
|
||||
aria-label="Filter representatives"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<Navbar title="Abgeordnete" />
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<Searchbar
|
||||
value={search}
|
||||
onInput={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch("")}
|
||||
placeholder="Name filtern…"
|
||||
/>
|
||||
|
||||
<div className="px-4 pb-20 space-y-3">
|
||||
{groups.map((group) => {
|
||||
const meta = getPartyMeta(group.partyLabel)
|
||||
return (
|
||||
<Card key={group.partyLabel}>
|
||||
<Card key={group.partyLabel} outline contentWrap={false}>
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border"
|
||||
className="flex items-center gap-2.5 px-4 py-2.5"
|
||||
style={{ borderLeftWidth: 4, borderLeftColor: meta.color }}
|
||||
>
|
||||
<span
|
||||
@@ -114,47 +113,44 @@ export function PoliticianSearch() {
|
||||
{meta.short.slice(0, 3)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{group.partyLabel}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{group.members.length}</span>
|
||||
<span className="text-xs text-black/55 dark:text-white/55 ml-auto">{group.members.length}</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
<List nested dividers>
|
||||
{group.members.map((m) => {
|
||||
const followed = isFollowing("politician", m.politician.id)
|
||||
const fn = mandateFunction(m)
|
||||
return (
|
||||
<li
|
||||
<ListItem
|
||||
key={m.id}
|
||||
className="flex items-center justify-between pl-4 pr-4 py-2.5"
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: meta.color }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{m.politician.label}</p>
|
||||
{fn && <p className="text-xs text-muted-foreground mt-0.5">{fn}</p>}
|
||||
</div>
|
||||
<Button
|
||||
variant={followed ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
followed
|
||||
? unfollow("politician", m.politician.id)
|
||||
: follow("politician", m.politician.id, m.politician.label)
|
||||
}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `Unfollow ${m.politician.label}` : `Follow ${m.politician.label}`}
|
||||
className="ml-4 shrink-0 rounded-full"
|
||||
>
|
||||
{followed ? "Following" : "Follow"}
|
||||
</Button>
|
||||
</li>
|
||||
title={m.politician.label}
|
||||
subtitle={fn}
|
||||
after={
|
||||
<Button
|
||||
small
|
||||
rounded
|
||||
outline={!followed}
|
||||
onClick={() =>
|
||||
followed
|
||||
? unfollow("politician", m.politician.id)
|
||||
: follow("politician", m.politician.id, m.politician.label)
|
||||
}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`}
|
||||
>
|
||||
{followed ? "Folgst du" : "Folgen"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</List>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{groups.length === 0 && search && (
|
||||
<p className="text-center text-sm text-muted-foreground py-6">No members matching "{search}"</p>
|
||||
<p className="text-center text-sm text-black/55 dark:text-white/55 py-6">Keine Abgeordneten für „{search}"</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Block, Button, Navbar, NavbarBackLink } from "konsta/react"
|
||||
|
||||
interface NotificationGuideProps {
|
||||
onBack: () => void
|
||||
@@ -6,100 +6,87 @@ interface NotificationGuideProps {
|
||||
|
||||
export function NotificationGuide({ onBack }: NotificationGuideProps) {
|
||||
return (
|
||||
<main className="p-4">
|
||||
<button type="button" onClick={onBack} className="flex items-center gap-1 text-primary text-sm mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Einstellungen
|
||||
</button>
|
||||
<>
|
||||
<Navbar title="iPhone-Einrichtung" left={<NavbarBackLink onClick={onBack} text="Zurück" showText />} />
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">Benachrichtigungen einrichten</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Push-Benachrichtigungen funktionieren auf dem iPhone nur, wenn die App zum Homescreen hinzugefügt wurde.
|
||||
</p>
|
||||
<Block>
|
||||
<p className="text-sm text-black/55 dark:text-white/55 mb-6">
|
||||
Push-Benachrichtigungen funktionieren auf dem iPhone nur, wenn die App zum Homescreen hinzugefügt wurde.
|
||||
</p>
|
||||
|
||||
<ol className="space-y-6">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
|
||||
1
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Teilen-Menü öffnen</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Tippe auf das Teilen-Symbol{" "}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="inline w-4 h-4 align-text-bottom"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>{" "}
|
||||
in der Safari-Leiste unten.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<ol className="space-y-6">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-white text-sm font-semibold shrink-0">
|
||||
1
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Teilen-Menü öffnen</p>
|
||||
<p className="text-sm text-black/55 dark:text-white/55 mt-1">
|
||||
Tippe auf das Teilen-Symbol{" "}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="inline w-4 h-4 align-text-bottom"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>{" "}
|
||||
in der Safari-Leiste unten.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
|
||||
2
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Zum Home-Bildschirm</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Scrolle im Menü nach unten und wähle{" "}
|
||||
<span className="font-medium text-foreground">Zum Home-Bildschirm</span>.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-white text-sm font-semibold shrink-0">
|
||||
2
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Zum Home-Bildschirm</p>
|
||||
<p className="text-sm text-black/55 dark:text-white/55 mt-1">
|
||||
Scrolle im Menü nach unten und wähle <span className="font-medium">Zum Home-Bildschirm</span>.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
|
||||
3
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">App hinzufügen</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Bestätige mit <span className="font-medium text-foreground">Hinzufügen</span>. Die App erscheint als Icon
|
||||
auf deinem Homescreen.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-white text-sm font-semibold shrink-0">
|
||||
3
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">App hinzufügen</p>
|
||||
<p className="text-sm text-black/55 dark:text-white/55 mt-1">
|
||||
Bestätige mit <span className="font-medium">Hinzufügen</span>. Die App erscheint als Icon auf deinem
|
||||
Homescreen.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
|
||||
4
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">App öffnen & Benachrichtigungen aktivieren</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Öffne die App vom Homescreen aus und aktiviere die Benachrichtigungen in den Einstellungen.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-white text-sm font-semibold shrink-0">
|
||||
4
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">App öffnen & Benachrichtigungen aktivieren</p>
|
||||
<p className="text-sm text-black/55 dark:text-white/55 mt-1">
|
||||
Öffne die App vom Homescreen aus und aktiviere die Benachrichtigungen in den Einstellungen.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="mt-8">
|
||||
<Button variant="outline" onClick={onBack} className="w-full">
|
||||
Zurück zu den Einstellungen
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
<div className="mt-8">
|
||||
<Button outline onClick={onBack}>
|
||||
Zurück zu den Einstellungen
|
||||
</Button>
|
||||
</div>
|
||||
</Block>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { Switch } from "@/shared/components/ui/switch"
|
||||
import { useDeviceId } from "@/shared/hooks/use-device-id"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { usePush } from "@/shared/hooks/use-push"
|
||||
import { usePwaUpdate } from "@/shared/hooks/use-pwa-update"
|
||||
import { fetchTopics } from "@/shared/lib/aw-api"
|
||||
import { BACKEND_URL, VAPID_PUBLIC_KEY } from "@/shared/lib/constants"
|
||||
import { BlockTitle, Button, List, ListItem, Navbar, Preloader, Toggle } from "konsta/react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type GeoResult, clearGeoCache, detectFromCoords, loadCachedResult } from "../../location/lib/geo"
|
||||
import { NotificationGuide } from "./notification-guide"
|
||||
@@ -96,325 +94,241 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="p-4 space-y-6">
|
||||
<div className="pb-20">
|
||||
<Navbar title="Einstellungen" />
|
||||
|
||||
{/* --- Notifications --- */}
|
||||
{VAPID_PUBLIC_KEY && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">
|
||||
Benachrichtigungen
|
||||
</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{push.permission === "denied" ? (
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm text-destructive">
|
||||
<>
|
||||
<BlockTitle>Benachrichtigungen</BlockTitle>
|
||||
<List inset strong>
|
||||
{push.permission === "denied" ? (
|
||||
<ListItem
|
||||
title={
|
||||
<span className="text-red-500">
|
||||
Benachrichtigungen sind blockiert. Bitte in den Systemeinstellungen aktivieren.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<label htmlFor="push-toggle" className="text-sm font-normal">
|
||||
Push-Benachrichtigungen
|
||||
</label>
|
||||
<Switch
|
||||
id="push-toggle"
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
label
|
||||
title="Push-Benachrichtigungen"
|
||||
after={
|
||||
<Toggle
|
||||
checked={push.subscribed}
|
||||
disabled={push.loading}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) push.subscribe()
|
||||
else push.unsubscribe()
|
||||
onChange={() => {
|
||||
if (push.subscribed) push.unsubscribe()
|
||||
else push.subscribe()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!standalone && (
|
||||
<>
|
||||
<div className="border-t border-border mx-4" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGuide(true)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 text-left"
|
||||
>
|
||||
<span className="text-sm">Einrichtung auf dem iPhone</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!standalone && <ListItem link title="Einrichtung auf dem iPhone" onClick={() => setShowGuide(true)} />}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* --- Location --- */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">Standort</h2>
|
||||
<BlockTitle>Standort</BlockTitle>
|
||||
{hasLocation && result.bundesland ? (
|
||||
<List inset strong>
|
||||
<ListItem title="Bundesland" after={result.bundesland} />
|
||||
{result.landtag_label && <ListItem title="Landtag" after={result.landtag_label} />}
|
||||
<ListItem title="Abgeordnete geladen" after={String(result.mandates.length)} />
|
||||
{result.cachedAt && <ListItem title="Zwischengespeichert" after={formatCacheAge(result.cachedAt)} />}
|
||||
</List>
|
||||
) : null}
|
||||
|
||||
{hasLocation && result.bundesland ? (
|
||||
<Card className="mb-3">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Bundesland</span>
|
||||
<span className="text-sm text-muted-foreground">{result.bundesland}</span>
|
||||
</div>
|
||||
</div>
|
||||
{result.landtag_label && (
|
||||
<>
|
||||
<div className="border-t border-border mx-4" />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Landtag</span>
|
||||
<span className="text-sm text-muted-foreground">{result.landtag_label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="border-t border-border mx-4" />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Abgeordnete geladen</span>
|
||||
<span className="text-sm text-muted-foreground">{result.mandates.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
{result.cachedAt && (
|
||||
<>
|
||||
<div className="border-t border-border mx-4" />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Zwischengespeichert</span>
|
||||
<span className="text-sm text-muted-foreground">{formatCacheAge(result.cachedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loading && (
|
||||
<output className="flex items-center justify-center h-16 mb-3" aria-label="Standort wird bestimmt">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span className="ml-3 text-sm text-muted-foreground">{hasLocation ? "Aktualisiere…" : "Erkenne…"}</span>
|
||||
</output>
|
||||
)}
|
||||
|
||||
{errorMsg && (
|
||||
<div className="mb-3 p-3 bg-destructive/10 rounded-lg text-destructive text-sm" role="alert">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{!hasLocation && !loading && (
|
||||
<Button onClick={() => detect(false)} className="w-full">
|
||||
Standort erkennen
|
||||
</Button>
|
||||
)}
|
||||
{hasLocation && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => detect(true)} disabled={loading} className="w-full">
|
||||
Abgeordnete neu laden
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleClearCache} disabled={loading} className="w-full">
|
||||
Cache löschen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-16 mb-3">
|
||||
<Preloader />
|
||||
<span className="ml-3 text-sm text-black/55 dark:text-white/55">
|
||||
{hasLocation ? "Aktualisiere…" : "Erkenne…"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{errorMsg && (
|
||||
<div className="mx-4 mb-3 p-3 bg-red-500/10 rounded-lg text-red-500 text-sm" role="alert">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 px-4">
|
||||
{!hasLocation && !loading && (
|
||||
<Button large onClick={() => detect(false)}>
|
||||
Standort erkennen
|
||||
</Button>
|
||||
)}
|
||||
{hasLocation && (
|
||||
<>
|
||||
<Button small outline onClick={() => detect(true)} disabled={loading}>
|
||||
Abgeordnete neu laden
|
||||
</Button>
|
||||
<Button small outline onClick={handleClearCache} disabled={loading}>
|
||||
Cache löschen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- App Update --- */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">App-Update</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{needRefresh ? (
|
||||
<>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Neue Version verfügbar</span>
|
||||
<Button size="sm" onClick={applyUpdate}>
|
||||
Jetzt aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">App ist aktuell</span>
|
||||
<Button variant="outline" size="xs" onClick={handleCheckUpdate} disabled={checking}>
|
||||
{checking ? "Prüfe…" : "Prüfen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
<BlockTitle className="mt-8">App-Update</BlockTitle>
|
||||
<List inset strong>
|
||||
{needRefresh ? (
|
||||
<ListItem
|
||||
title="Neue Version verfügbar"
|
||||
after={
|
||||
<Button small onClick={applyUpdate}>
|
||||
Jetzt aktualisieren
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
title="App ist aktuell"
|
||||
after={
|
||||
<Button small outline onClick={handleCheckUpdate} disabled={checking}>
|
||||
{checking ? "Prüfe…" : "Prüfen"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
|
||||
{/* --- About --- */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">Info</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Datenquelle</span>
|
||||
<a
|
||||
href="https://www.abgeordnetenwatch.de"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary"
|
||||
>
|
||||
abgeordnetenwatch.de
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border mx-4" />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Geräte-ID</span>
|
||||
<span className="text-xs text-muted-foreground font-mono max-w-[50%] truncate">{deviceId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
<BlockTitle>Info</BlockTitle>
|
||||
<List inset strong>
|
||||
<ListItem
|
||||
title="Datenquelle"
|
||||
after={
|
||||
<a
|
||||
href="https://www.abgeordnetenwatch.de"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary"
|
||||
>
|
||||
abgeordnetenwatch.de
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Geräte-ID"
|
||||
after={<span className="font-mono text-xs max-w-[50%] truncate">{deviceId}</span>}
|
||||
/>
|
||||
</List>
|
||||
|
||||
{/* --- Developer --- */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">Entwickler</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* Backend Health */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Backend Health</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devHealth && (
|
||||
<span className={`text-xs ${devHealth === "ok" ? "text-green-600" : "text-destructive"}`}>
|
||||
{devHealth}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
setDevHealth(null)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/health`)
|
||||
setDevHealth(res.ok ? "ok" : `${res.status}`)
|
||||
} catch (e) {
|
||||
setDevHealth(String(e))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Prüfen
|
||||
</Button>
|
||||
</div>
|
||||
<BlockTitle>Entwickler</BlockTitle>
|
||||
<List inset strong>
|
||||
<ListItem
|
||||
title="Backend Health"
|
||||
after={
|
||||
<div className="flex items-center gap-2">
|
||||
{devHealth && (
|
||||
<span className={`text-xs ${devHealth === "ok" ? "text-green-600" : "text-red-500"}`}>{devHealth}</span>
|
||||
)}
|
||||
<Button
|
||||
small
|
||||
outline
|
||||
onClick={async () => {
|
||||
setDevHealth(null)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/health`)
|
||||
setDevHealth(res.ok ? "ok" : `${res.status}`)
|
||||
} catch (e) {
|
||||
setDevHealth(String(e))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Prüfen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mx-4" />
|
||||
|
||||
{/* Test Push */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Test-Benachrichtigung</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devPush && (
|
||||
<span className={`text-xs ${devPush === "ok" ? "text-green-600" : "text-destructive"}`}>
|
||||
{devPush}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
setDevPush(null)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/push/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ device_id: deviceId }),
|
||||
})
|
||||
setDevPush(res.ok ? "ok" : `${res.status}`)
|
||||
} catch (e) {
|
||||
setDevPush(String(e))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Senden
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Test-Push"
|
||||
after={
|
||||
<div className="flex items-center gap-2">
|
||||
{devPush && (
|
||||
<span className={`text-xs ${devPush === "ok" ? "text-green-600" : "text-red-500"}`}>{devPush}</span>
|
||||
)}
|
||||
<Button
|
||||
small
|
||||
outline
|
||||
onClick={async () => {
|
||||
setDevPush(null)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/push/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ device_id: deviceId }),
|
||||
})
|
||||
setDevPush(res.ok ? "ok" : `${res.status}`)
|
||||
} catch (e) {
|
||||
setDevPush(String(e))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Senden
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mx-4" />
|
||||
|
||||
{/* Follow all topics */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Alle Themen folgen</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devTopics && <span className="text-xs text-green-600">{devTopics}</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
setDevTopics(null)
|
||||
try {
|
||||
const topics = await fetchTopics()
|
||||
for (const t of topics) follow("topic", t.id, t.label)
|
||||
setDevTopics(`${topics.length}`)
|
||||
} catch (e) {
|
||||
setDevTopics(String(e))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Folgen
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Alle Themen folgen"
|
||||
after={
|
||||
<div className="flex items-center gap-2">
|
||||
{devTopics && <span className="text-xs text-green-600">{devTopics}</span>}
|
||||
<Button
|
||||
small
|
||||
outline
|
||||
onClick={async () => {
|
||||
setDevTopics(null)
|
||||
try {
|
||||
const topics = await fetchTopics()
|
||||
for (const t of topics) follow("topic", t.id, t.label)
|
||||
setDevTopics(`${topics.length}`)
|
||||
} catch (e) {
|
||||
setDevTopics(String(e))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Folgen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mx-4" />
|
||||
|
||||
{/* Follow all politicians */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Alle Abgeordnete folgen</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devPoliticians && <span className="text-xs text-green-600">{devPoliticians}</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setDevPoliticians(null)
|
||||
const cached = loadCachedResult()
|
||||
if (!cached || cached.mandates.length === 0) {
|
||||
setDevPoliticians("Kein Standort-Cache")
|
||||
return
|
||||
}
|
||||
for (const m of cached.mandates) {
|
||||
follow("politician", m.politician.id, m.politician.label)
|
||||
}
|
||||
setDevPoliticians(`${cached.mandates.length}`)
|
||||
}}
|
||||
>
|
||||
Folgen
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Alle Abgeordnete folgen"
|
||||
after={
|
||||
<div className="flex items-center gap-2">
|
||||
{devPoliticians && <span className="text-xs text-green-600">{devPoliticians}</span>}
|
||||
<Button
|
||||
small
|
||||
outline
|
||||
onClick={() => {
|
||||
setDevPoliticians(null)
|
||||
const cached = loadCachedResult()
|
||||
if (!cached || cached.mandates.length === 0) {
|
||||
setDevPoliticians("Kein Standort-Cache")
|
||||
return
|
||||
}
|
||||
for (const m of cached.mandates) {
|
||||
follow("politician", m.politician.id, m.politician.label)
|
||||
}
|
||||
setDevPoliticians(`${cached.mandates.length}`)
|
||||
}}
|
||||
>
|
||||
Folgen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { Button, List, ListItem, Navbar, Preloader, Searchbar } from "konsta/react"
|
||||
import { useState } from "react"
|
||||
import { useTopics } from "../hooks/use-topics"
|
||||
|
||||
@@ -12,49 +11,51 @@ export function TopicList() {
|
||||
const filtered = topics.filter((t) => t.label.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="p-4 border-b border-border">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search topics…"
|
||||
aria-label="Search topics"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<Navbar title="Themen" />
|
||||
|
||||
<Searchbar
|
||||
value={search}
|
||||
onInput={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch("")}
|
||||
placeholder="Themen suchen…"
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<output className="flex items-center justify-center h-48" aria-label="Loading topics">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</output>
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Preloader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 text-destructive" role="alert">
|
||||
<div className="p-4 text-red-500" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-border">
|
||||
<List dividers className="pb-20">
|
||||
{filtered.map((topic) => {
|
||||
const followed = isFollowing("topic", topic.id)
|
||||
return (
|
||||
<li key={topic.id} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">{topic.label}</span>
|
||||
<Button
|
||||
variant={followed ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => (followed ? unfollow("topic", topic.id) : follow("topic", topic.id, topic.label))}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `Unfollow ${topic.label}` : `Follow ${topic.label}`}
|
||||
className="ml-4 shrink-0 rounded-full"
|
||||
>
|
||||
{followed ? "Following" : "Follow"}
|
||||
</Button>
|
||||
</li>
|
||||
<ListItem
|
||||
key={topic.id}
|
||||
title={topic.label}
|
||||
after={
|
||||
<Button
|
||||
small
|
||||
rounded
|
||||
outline={!followed}
|
||||
onClick={() => (followed ? unfollow("topic", topic.id) : follow("topic", topic.id, topic.label))}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `${topic.label} entfolgen` : `${topic.label} folgen`}
|
||||
>
|
||||
{followed ? "Folgst du" : "Folgen"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</main>
|
||||
</List>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user