- 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
472 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|