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:
2026-03-02 08:14:22 +01:00
commit 4e3aa682ac
51 changed files with 4131 additions and 0 deletions
@@ -0,0 +1,8 @@
export function FeedEmpty() {
return (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground px-6 text-center">
<p className="text-lg font-medium">Your feed is empty</p>
<p className="text-sm mt-2">Follow topics or politicians in the other tabs to see polls here.</p>
</div>
)
}
@@ -0,0 +1,53 @@
import { Badge } from "@/shared/components/ui/badge"
import type { FeedItem as FeedItemType } from "../lib/assemble-feed"
function formatDate(iso: string | null): string {
if (!iso) return ""
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })
}
export function FeedItemCard({ item }: { item: FeedItemType }) {
return (
<article className="p-4">
<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"
>
{item.title}
</a>
) : (
<h2 className="text-base font-medium leading-snug">{item.title}</h2>
)}
{item.date && (
<time dateTime={item.date} className="text-xs text-muted-foreground 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">
{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>
</a>
) : (
<Badge key={topic.label} variant="secondary">
{topic.label}
</Badge>
),
)}
</div>
)}
<p className="text-xs text-muted-foreground mt-1">{item.source}</p>
</article>
)
}
@@ -0,0 +1,15 @@
import type { FeedItem } from "../lib/assemble-feed"
import { FeedEmpty } from "./feed-empty"
import { FeedItemCard } from "./feed-item"
export function FeedList({ items }: { items: FeedItem[] }) {
if (items.length === 0) return <FeedEmpty />
return (
<main className="divide-y divide-border">
{items.map((item) => (
<FeedItemCard key={item.id} item={item} />
))}
</main>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { useFollows } from "@/shared/hooks/use-follows"
import { useCallback, useEffect, useMemo, useState } from "react"
import { type FeedItem, assembleFeed } from "../lib/assemble-feed"
export function useFeed() {
const { follows } = useFollows()
const [items, setItems] = useState<FeedItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const topicIDs = useMemo(() => follows.filter((f) => f.type === "topic").map((f) => f.entity_id), [follows])
const politicianIDs = useMemo(() => follows.filter((f) => f.type === "politician").map((f) => f.entity_id), [follows])
const refresh = useCallback(async () => {
if (topicIDs.length === 0 && politicianIDs.length === 0) {
setItems([])
return
}
setLoading(true)
setError(null)
try {
const feed = await assembleFeed(topicIDs, politicianIDs)
setItems(feed)
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}, [topicIDs, politicianIDs])
useEffect(() => {
refresh()
}, [refresh])
return { items, loading, error, refresh }
}
+2
View File
@@ -0,0 +1,2 @@
export { FeedList } from "./components/feed-list"
export { useFeed } from "./hooks/use-feed"