335 lines
9.0 KiB
TypeScript
335 lines
9.0 KiB
TypeScript
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"
|
|
|
|
function formatCacheAge(cachedAt: number): string {
|
|
const minutes = Math.floor((Date.now() - cachedAt) / 60_000)
|
|
if (minutes < 1) return "gerade eben"
|
|
if (minutes < 60) return `vor ${minutes} Min.`
|
|
const hours = Math.floor(minutes / 60)
|
|
if (hours < 24) return `vor ${hours} Std.`
|
|
const days = Math.floor(hours / 24)
|
|
return `vor ${days} T.`
|
|
}
|
|
|
|
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 deviceId = useDeviceId()
|
|
const { needRefresh, checkForUpdate, applyUpdate } = usePwaUpdate()
|
|
const push = usePush()
|
|
const { follow } = 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)
|
|
|
|
useEffect(() => {
|
|
const cached = loadCachedResult()
|
|
if (cached) setResult(cached)
|
|
}, [])
|
|
|
|
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(pos.coords.latitude, pos.coords.longitude, skipCache)
|
|
setResult(r)
|
|
} catch (e) {
|
|
setErrorMsg(String(e))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
},
|
|
(err) => {
|
|
setErrorMsg(err.message)
|
|
setLoading(false)
|
|
},
|
|
)
|
|
}, [])
|
|
|
|
function handleClearCache() {
|
|
clearGeoCache()
|
|
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="pb-20">
|
|
<Navbar title="Einstellungen" />
|
|
|
|
{/* --- Notifications --- */}
|
|
{VAPID_PUBLIC_KEY && (
|
|
<>
|
|
<BlockTitle>Benachrichtigungen</BlockTitle>
|
|
<List inset strong>
|
|
{push.permission === "denied" ? (
|
|
<ListItem
|
|
title={
|
|
<span className="text-red-500">
|
|
Benachrichtigungen sind blockiert. Bitte in den Systemeinstellungen aktivieren.
|
|
</span>
|
|
}
|
|
/>
|
|
) : (
|
|
<ListItem
|
|
label
|
|
title="Push-Benachrichtigungen"
|
|
after={
|
|
<Toggle
|
|
checked={push.subscribed}
|
|
disabled={push.loading}
|
|
onChange={() => {
|
|
if (push.subscribed) push.unsubscribe()
|
|
else push.subscribe()
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
{!standalone && <ListItem link title="Einrichtung auf dem iPhone" onClick={() => setShowGuide(true)} />}
|
|
</List>
|
|
</>
|
|
)}
|
|
|
|
{/* --- Location --- */}
|
|
<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}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{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 --- */}
|
|
<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 --- */}
|
|
<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 --- */}
|
|
<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>
|
|
}
|
|
/>
|
|
<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>
|
|
}
|
|
/>
|
|
<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>
|
|
}
|
|
/>
|
|
<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>
|
|
}
|
|
/>
|
|
</List>
|
|
</div>
|
|
)
|
|
}
|