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,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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FeedList } from "./components/feed-list"
|
||||
export { useFeed } from "./hooks/use-feed"
|
||||
Reference in New Issue
Block a user