Files
agw/src/client/features/settings/components/settings-page.tsx
Hermes cf5646bbd9 standardize design to s/w standard - fix non-standard color usage
- replace hardcoded green/red/blue colors with standard shadcn semantic colors
- use text-primary, text-secondary-foreground, text-muted-foreground instead of text-green-600/blue-600
- replace colored backgrounds with bg-secondary, bg-muted, bg-primary/5
- maintain proper contrast and accessibility with semantic color tokens
- ensure consistent black/white theme across all components
2026-03-12 18:03:06 +01:00

472 lines
14 KiB
TypeScript

import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Switch } from "@/shared/components/ui/switch"
import { useDb } from "@/shared/db/provider"
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 {
APP_VERSION,
BACKEND_URL,
STORAGE_KEYS,
VAPID_PUBLIC_KEY,
} from "@/shared/lib/constants"
import { useCallback, useEffect, useState } from "react"
import {
type GeoResult,
clearGeoCache,
detectFromCoords,
loadCachedResult,
} from "../../location/lib/geo"
import { NotificationGuide } from "./notification-guide"
function isStandalone(): boolean {
if (typeof window === "undefined") return false
return (
window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator &&
(navigator as { standalone?: boolean }).standalone === true)
)
}
export function SettingsPage() {
const db = useDb()
const deviceId = useDeviceId()
const { needRefresh, checkForUpdate, applyUpdate } = usePwaUpdate()
const push = usePush()
const { follow, unfollowAllTopics, unfollowAllPoliticians } = useFollows()
const [checking, setChecking] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<GeoResult | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [showGuide, setShowGuide] = useState(false)
const [devHealth, setDevHealth] = useState<string | null>(null)
const [devPush, setDevPush] = useState<string | null>(null)
const [devTopics, setDevTopics] = useState<string | null>(null)
const [devPoliticians, setDevPoliticians] = useState<string | null>(null)
const [devUnfollowTopics, setDevUnfollowTopics] = useState<string | null>(
null,
)
const [devUnfollowPoliticians, setDevUnfollowPoliticians] = useState<
string | null
>(null)
const [devReload, setDevReload] = useState<string | null>(null)
const [devMode, setDevMode] = useState(
() => localStorage.getItem(STORAGE_KEYS.devMode) === "true",
)
useEffect(() => {
loadCachedResult(db).then((cached) => {
if (cached) setResult(cached)
})
}, [db])
const detect = useCallback(
(skipCache: boolean) => {
if (!navigator.geolocation) {
setErrorMsg("Standortbestimmung wird nicht unterstützt")
return
}
setLoading(true)
setErrorMsg(null)
navigator.geolocation.getCurrentPosition(
async (pos) => {
try {
const r = await detectFromCoords(
db,
pos.coords.latitude,
pos.coords.longitude,
skipCache,
)
setResult(r)
} catch (e) {
setErrorMsg(String(e))
} finally {
setLoading(false)
}
},
(err) => {
setErrorMsg(err.message)
setLoading(false)
},
)
},
[db],
)
function handleClearCache() {
clearGeoCache(db)
setResult(null)
}
async function handleCheckUpdate() {
setChecking(true)
try {
await checkForUpdate()
} finally {
setChecking(false)
}
}
const hasLocation = result && result.mandates.length > 0
const standalone = isStandalone()
if (showGuide) {
return <NotificationGuide onBack={() => setShowGuide(false)} />
}
return (
<div className="px-4 py-4 space-y-6 pb-4">
{/* --- Permissions: Push + Location --- */}
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Berechtigungen
</h2>
<Card className="py-0 gap-0">
<CardContent className="p-0 divide-y divide-border">
{VAPID_PUBLIC_KEY &&
(push.permission === "denied" ? (
<div className="px-4 py-3">
<span className="text-destructive text-sm">
Push blockiert bitte in den Systemeinstellungen
aktivieren.
</span>
</div>
) : (
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm" id="push-label">
Push-Benachrichtigungen
</span>
<Switch
checked={push.subscribed}
disabled={push.loading}
aria-labelledby="push-label"
onCheckedChange={() => {
if (push.subscribed) push.unsubscribe()
else push.subscribe()
}}
/>
</div>
))}
<div className="flex items-center justify-between px-4 py-3">
<div>
<span className="text-sm" id="location-label">
Standort
</span>
{loading && (
<span className="ml-2 text-xs text-muted-foreground">
{hasLocation ? "Aktualisiere…" : "Erkenne…"}
</span>
)}
</div>
<Switch
checked={!!hasLocation}
disabled={loading}
aria-labelledby="location-label"
onCheckedChange={(checked) => {
if (checked) detect(false)
else handleClearCache()
}}
/>
</div>
{errorMsg && (
<div className="px-4 py-3">
<span className="text-destructive text-sm">{errorMsg}</span>
</div>
)}
{!standalone && (
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 text-sm hover:bg-muted transition-colors"
onClick={() => setShowGuide(true)}
>
Einrichtung auf dem iPhone
<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>
{/* --- Info --- */}
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Info
</h2>
<Card className="py-0 gap-0">
<CardContent className="p-0 divide-y divide-border">
<div className="flex justify-between px-4 py-3">
<span className="text-sm">Version</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{APP_VERSION}
</span>
{needRefresh ? (
<Button size="sm" onClick={applyUpdate}>
Aktualisieren
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={handleCheckUpdate}
disabled={checking}
>
{checking ? "Prüfe…" : "Prüfen"}
</Button>
)}
</div>
</div>
<div className="flex justify-between px-4 py-3">
<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 className="flex justify-between px-4 py-3">
<span className="text-sm">Geräte-ID</span>
<span className="font-mono text-xs max-w-[50%] truncate">
{deviceId}
</span>
</div>
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm" id="dev-mode-label">
Entwicklermodus
</span>
<Switch
checked={devMode}
aria-labelledby="dev-mode-label"
onCheckedChange={async (checked) => {
setDevMode(checked)
localStorage.setItem(STORAGE_KEYS.devMode, String(checked))
try {
await fetch(`${BACKEND_URL}/legislation/seed-mock`, {
method: checked ? "POST" : "DELETE",
})
} catch {
// best-effort
}
}}
/>
</div>
</CardContent>
</Card>
</section>
{/* --- Developer (visible only in dev mode) --- */}
{devMode && (
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Entwickler
</h2>
<Card className="py-0 gap-0">
<CardContent className="p-0 divide-y divide-border">
<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-foreground" : "text-destructive"}`}
>
{devHealth}
</span>
)}
<Button
size="sm"
variant="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>
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Test-Push</span>
<div className="flex items-center gap-2">
{devPush && (
<span
className={`text-xs ${devPush === "ok" ? "text-foreground" : "text-destructive"}`}
>
{devPush}
</span>
)}
<Button
size="sm"
variant="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>
<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-muted-foreground">
{devTopics}
</span>
)}
<Button
size="sm"
variant="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>
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Allen Themen entfolgen</span>
<div className="flex items-center gap-2">
{devUnfollowTopics && (
<span className="text-xs text-muted-foreground">
{devUnfollowTopics}
</span>
)}
<Button
size="sm"
variant="outline"
onClick={async () => {
setDevUnfollowTopics(null)
await unfollowAllTopics()
setDevUnfollowTopics("ok")
}}
>
Entfolgen
</Button>
</div>
</div>
<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-muted-foreground">
{devPoliticians}
</span>
)}
<Button
size="sm"
variant="outline"
onClick={async () => {
setDevPoliticians(null)
const cached = await loadCachedResult(db)
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>
</div>
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Allen Abgeordneten entfolgen</span>
<div className="flex items-center gap-2">
{devUnfollowPoliticians && (
<span className="text-xs text-muted-foreground">
{devUnfollowPoliticians}
</span>
)}
<Button
size="sm"
variant="outline"
onClick={async () => {
setDevUnfollowPoliticians(null)
await unfollowAllPoliticians()
setDevUnfollowPoliticians("ok")
}}
>
Entfolgen
</Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Abgeordnete neu laden</span>
<div className="flex items-center gap-2">
{devReload && (
<span className="text-xs text-muted-foreground">
{devReload}
</span>
)}
<Button
size="sm"
variant="outline"
disabled={loading || !hasLocation}
onClick={() => {
setDevReload(null)
detect(true)
setDevReload("gestartet")
}}
>
Neu laden
</Button>
</div>
</div>
</CardContent>
</Card>
</section>
)}
</div>
)
}