abgeordnetenwatch PWA + backend
feature-based React PWA with Hono backend: - feed from abgeordnetenwatch.de API (polls by topic + politician) - follow topics, search and follow politicians - geo-based politician discovery via Nominatim - push notifications for new polls via web-push - service worker with offline caching - deploy to Uberspace 8 (systemd, PostgreSQL, web backend proxy) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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 { usePush } from "@/shared/hooks/use-push"
|
||||
import { usePwaUpdate } from "@/shared/hooks/use-pwa-update"
|
||||
import { 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 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 [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)
|
||||
|
||||
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 (
|
||||
<main className="p-4 space-y-6">
|
||||
{/* --- 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">
|
||||
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"
|
||||
checked={push.subscribed}
|
||||
disabled={push.loading}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) push.subscribe()
|
||||
else push.unsubscribe()
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* --- Location --- */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">Standort</h2>
|
||||
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- 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>
|
||||
|
||||
{/* --- 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>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user