restructure app into home/bundestag/landtag/settings tabs, add DIP API for upcoming votes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
AGENTS.md
27
AGENTS.md
@@ -12,7 +12,7 @@ Abgeordnetenwatch PWA + Backend — a progressive web app that lets users follow
|
||||
|---|---|
|
||||
| Runtime | Bun |
|
||||
| Build | Vite + TanStack Router Plugin + vite-plugin-pwa (injectManifest) |
|
||||
| UI | React 19 + Konsta UI (mobile) + shadcn/ui (desktop, future) + Tailwind CSS v4 |
|
||||
| UI | React 19 + shadcn/ui + Tailwind CSS v4 |
|
||||
| Routing | TanStack Router (file-based, `src/routes/app/` for mobile) |
|
||||
| State | Zustand (ephemeral UI), PGlite (persistent data via IndexedDB) |
|
||||
| Validation | Zod |
|
||||
@@ -35,19 +35,28 @@ Abgeordnetenwatch PWA + Backend — a progressive web app that lets users follow
|
||||
|
||||
```
|
||||
src/ PWA source
|
||||
├── features/ Feature modules (feed, topics, politicians, location, settings)
|
||||
│ └── <feature>/ components/, hooks/, lib/, store.ts, index.ts
|
||||
├── features/ Feature modules
|
||||
│ ├── home/ Home tab (placeholder)
|
||||
│ ├── bundestag/ Bundestag tab: DIP + AW feed, configure (topics + reps)
|
||||
│ ├── landtag/ Landtag tab: representatives by geo-detection, configure
|
||||
│ ├── feed/ Shared feed components (FeedList, FeedItemCard, useFeed)
|
||||
│ ├── location/ Geo-detection, Bundestag mandate caching, party metadata
|
||||
│ ├── topics/ useTopics hook (shared by configure pages)
|
||||
│ └── settings/ Settings page (notifications, location, updates)
|
||||
├── shared/ Shared code
|
||||
│ ├── components/ui/ shadcn components (reserved for desktop routes)
|
||||
│ ├── components/ representative-list, topic-toggle-list
|
||||
│ │ └── ui/ shadcn components
|
||||
│ ├── db/ PGlite client, migrations, data-access modules, DbProvider
|
||||
│ ├── hooks/ use-device-id, use-follows, use-push, use-pwa-update
|
||||
│ └── lib/ aw-api, constants, push-client, utils
|
||||
│ └── lib/ aw-api, dip-api, constants, push-client, utils
|
||||
├── routes/ TanStack Router file-based routes
|
||||
│ ├── app/ Mobile routes (Konsta UI layout + tabbar)
|
||||
│ └── index.tsx Redirect → /app/feed
|
||||
│ ├── app/ Tabs: Home / Bundestag / Landtag / Einstellungen
|
||||
│ │ ├── bundestag/ index (feed) + configure sub-route
|
||||
│ │ └── landtag/ index (reps) + configure sub-route
|
||||
│ └── index.tsx Redirect → /app/home
|
||||
├── sw.ts Custom service worker (precache + push handlers)
|
||||
├── app.tsx RouterProvider
|
||||
├── app.css Tailwind v4 + Konsta + shadcn theme
|
||||
├── app.css Tailwind v4 + shadcn theme
|
||||
└── main.tsx Entry point
|
||||
|
||||
server/ Backend source
|
||||
@@ -68,7 +77,7 @@ server/ Backend source
|
||||
|
||||
- PWA: static files deploy to Uberspace at `/var/www/virtual/<user>/html/agw/`
|
||||
- Backend: deployed as a supervisord service on Uberspace at `~/services/agw-backend/`
|
||||
- Backend API is reverse-proxied at `/agw-api/` via `uberspace web backend`
|
||||
- Backend API is reverse-proxied at `/agw/api/` via `uberspace web backend`
|
||||
- `./deploy.sh` builds and deploys both PWA and backend
|
||||
- Apache `.htaccess` handles SPA routing
|
||||
|
||||
|
||||
49
src/features/bundestag/components/bundestag-configure.tsx
Normal file
49
src/features/bundestag/components/bundestag-configure.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { RepresentativeList } from "@/shared/components/representative-list"
|
||||
import { TopicToggleList } from "@/shared/components/topic-toggle-list"
|
||||
import { useDb } from "@/shared/db/provider"
|
||||
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
||||
import { useEffect, useState } from "react"
|
||||
import { fetchAndCacheBundestagMandates } from "../../location/lib/geo"
|
||||
import { useBundestagUI } from "../store"
|
||||
|
||||
export function BundestagConfigure() {
|
||||
const db = useDb()
|
||||
const topicSearch = useBundestagUI((s) => s.topicSearch)
|
||||
const setTopicSearch = useBundestagUI((s) => s.setTopicSearch)
|
||||
const politicianSearch = useBundestagUI((s) => s.politicianSearch)
|
||||
const setPoliticianSearch = useBundestagUI((s) => s.setPoliticianSearch)
|
||||
|
||||
const [mandates, setMandates] = useState<MandateWithPolitician[]>([])
|
||||
const [loadingMandates, setLoadingMandates] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingMandates(true)
|
||||
fetchAndCacheBundestagMandates(db)
|
||||
.then(setMandates)
|
||||
.finally(() => setLoadingMandates(false))
|
||||
}, [db])
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<section>
|
||||
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">Themen</h2>
|
||||
<TopicToggleList searchQuery={topicSearch} onSearchChange={setTopicSearch} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Abgeordnete
|
||||
</h2>
|
||||
{loadingMandates ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : mandates.length > 0 ? (
|
||||
<RepresentativeList mandates={mandates} searchQuery={politicianSearch} onSearchChange={setPoliticianSearch} />
|
||||
) : (
|
||||
<p className="px-4 text-sm text-muted-foreground">Keine Abgeordneten verfügbar.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/features/bundestag/components/bundestag-feed.tsx
Normal file
78
src/features/bundestag/components/bundestag-feed.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { FeedList } from "@/features/feed/components/feed-list"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useBundestagFeed } from "../hooks/use-bundestag-feed"
|
||||
|
||||
function formatCacheAge(timestamp: number): string {
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 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.`
|
||||
}
|
||||
|
||||
export function BundestagFeed() {
|
||||
const { items, loading, refreshing, error, lastUpdated, refresh } = useBundestagFeed()
|
||||
const hasItems = items.length > 0
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
{lastUpdated && (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
||||
<span className="text-xs text-muted-foreground">Aktualisiert {formatCacheAge(lastUpdated)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refresh({ silent: true })}
|
||||
disabled={refreshing}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors disabled:opacity-50"
|
||||
aria-label="Feed aktualisieren"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`w-5 h-5 text-muted-foreground ${refreshing ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !hasItems && (
|
||||
<output className="flex items-center justify-center h-48" aria-label="Feed wird geladen">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</output>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 text-destructive" role="alert">
|
||||
<p className="font-semibold">Fehler beim Laden</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasItems && <FeedList items={items} />}
|
||||
|
||||
{!hasItems && !loading && !error && (
|
||||
<div className="text-center mt-12 px-4">
|
||||
<p className="text-lg font-medium">Dein Bundestag-Feed ist leer</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Folge Themen oder Abgeordneten, um Abstimmungen hier zu sehen.
|
||||
</p>
|
||||
<Link to="/app/bundestag/configure" className="text-primary text-sm underline mt-4 inline-block">
|
||||
Bundestag konfigurieren
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
src/features/bundestag/hooks/use-bundestag-feed.ts
Normal file
125
src/features/bundestag/hooks/use-bundestag-feed.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { FeedItem } from "@/features/feed/lib/assemble-feed"
|
||||
import { loadFeedCache, mergeFeedItems, saveFeedCache } from "@/features/feed/lib/feed-cache"
|
||||
import { useDb } from "@/shared/db/provider"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { assembleBundestagFeed } from "../lib/assemble-bundestag-feed"
|
||||
|
||||
const CACHE_KEY = "bundestag_feed"
|
||||
const REFRESH_INTERVAL_MS = 60 * 60 * 1000
|
||||
|
||||
export function useBundestagFeed() {
|
||||
const db = useDb()
|
||||
const { follows } = useFollows()
|
||||
|
||||
const [items, setItems] = useState<FeedItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<number | 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 refreshingRef = useRef(false)
|
||||
const lastUpdatedRef = useRef<number | null>(null)
|
||||
const hasItemsRef = useRef(false)
|
||||
|
||||
// Load cache on mount
|
||||
useEffect(() => {
|
||||
loadFeedCache(db, CACHE_KEY).then((cached) => {
|
||||
if (cached && cached.items.length > 0) {
|
||||
setItems(cached.items)
|
||||
hasItemsRef.current = true
|
||||
setLastUpdated(cached.updatedAt)
|
||||
lastUpdatedRef.current = cached.updatedAt
|
||||
}
|
||||
})
|
||||
}, [db])
|
||||
|
||||
const refresh = useCallback(
|
||||
async (opts?: { silent?: boolean }) => {
|
||||
if (refreshingRef.current) return
|
||||
const silent = opts?.silent ?? false
|
||||
|
||||
if (topicIDs.length === 0 && politicianIDs.length === 0) return
|
||||
|
||||
refreshingRef.current = true
|
||||
if (silent) {
|
||||
setRefreshing(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const fresh = await assembleBundestagFeed(topicIDs, politicianIDs)
|
||||
setItems((prev) => {
|
||||
const merged = mergeFeedItems(prev, fresh)
|
||||
saveFeedCache(db, merged, CACHE_KEY)
|
||||
hasItemsRef.current = merged.length > 0
|
||||
return merged
|
||||
})
|
||||
const now = Date.now()
|
||||
setLastUpdated(now)
|
||||
lastUpdatedRef.current = now
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
refreshingRef.current = false
|
||||
}
|
||||
},
|
||||
[db, topicIDs, politicianIDs],
|
||||
)
|
||||
|
||||
// Initial fetch / refresh when follows change
|
||||
useEffect(() => {
|
||||
if (hasItemsRef.current) {
|
||||
refresh({ silent: true })
|
||||
} else {
|
||||
refresh()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
// Auto-refresh interval + visibility API
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startInterval() {
|
||||
if (intervalId) return
|
||||
intervalId = setInterval(() => {
|
||||
refresh({ silent: true })
|
||||
}, REFRESH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stopInterval() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibility() {
|
||||
if (document.hidden) {
|
||||
stopInterval()
|
||||
} else {
|
||||
if (!lastUpdatedRef.current || Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS) {
|
||||
refresh({ silent: true })
|
||||
}
|
||||
startInterval()
|
||||
}
|
||||
}
|
||||
|
||||
startInterval()
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
|
||||
return () => {
|
||||
stopInterval()
|
||||
document.removeEventListener("visibilitychange", handleVisibility)
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
return { items, loading, refreshing, error, lastUpdated, refresh }
|
||||
}
|
||||
3
src/features/bundestag/index.ts
Normal file
3
src/features/bundestag/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { BundestagFeed } from "./components/bundestag-feed"
|
||||
export { BundestagConfigure } from "./components/bundestag-configure"
|
||||
export { useBundestagFeed } from "./hooks/use-bundestag-feed"
|
||||
97
src/features/bundestag/lib/assemble-bundestag-feed.ts
Normal file
97
src/features/bundestag/lib/assemble-bundestag-feed.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { FeedItem } from "@/features/feed/lib/assemble-feed"
|
||||
import {
|
||||
type Poll,
|
||||
fetchCandidacyMandates,
|
||||
fetchPollsByIds,
|
||||
fetchPollsByLegislature,
|
||||
fetchTopics,
|
||||
fetchVotes,
|
||||
} from "@/shared/lib/aw-api"
|
||||
import { BUNDESTAG_LEGISLATURE_ID } from "@/shared/lib/constants"
|
||||
import { fetchUpcomingVorgaenge } from "@/shared/lib/dip-api"
|
||||
|
||||
export async function assembleBundestagFeed(
|
||||
followedTopicIDs: number[],
|
||||
followedPoliticianIDs: number[],
|
||||
): Promise<FeedItem[]> {
|
||||
const [topics, polls, vorgaenge] = await Promise.all([
|
||||
fetchTopics(),
|
||||
fetchPollsByLegislature(BUNDESTAG_LEGISLATURE_ID, 150),
|
||||
fetchUpcomingVorgaenge().catch(() => []),
|
||||
])
|
||||
|
||||
const topicMap = new Map(topics.map((t) => [t.id, t.label]))
|
||||
const topicLabelSet = new Set(topics.filter((t) => followedTopicIDs.includes(t.id)).map((t) => t.label.toLowerCase()))
|
||||
|
||||
// --- Past: AW API polls filtered by followed topics + politicians ---
|
||||
const topicSet = new Set(followedTopicIDs)
|
||||
const filteredByTopics = topicSet.size > 0 ? polls.filter((p) => p.field_topics.some((t) => topicSet.has(t.id))) : []
|
||||
|
||||
const politicianPolls = await fetchPollsForPoliticians(followedPoliticianIDs)
|
||||
|
||||
const combined = new Map<number, Poll>()
|
||||
for (const p of [...filteredByTopics, ...politicianPolls]) combined.set(p.id, p)
|
||||
|
||||
const pollItems: FeedItem[] = Array.from(combined.values()).map((poll) => ({
|
||||
id: `poll-${poll.id}`,
|
||||
kind: "poll",
|
||||
status: classifyPoll(poll),
|
||||
title: poll.label,
|
||||
url: poll.abgeordnetenwatch_url ?? null,
|
||||
date: poll.field_poll_date,
|
||||
topics: poll.field_topics.flatMap((t) => {
|
||||
const label = t.label ?? topicMap.get(t.id)
|
||||
return label ? [{ label, url: t.abgeordnetenwatch_url ?? null }] : []
|
||||
}),
|
||||
source: "Bundestag",
|
||||
}))
|
||||
|
||||
// --- Upcoming: DIP API Vorgänge, filtered by sachgebiet matching followed topic labels ---
|
||||
const vorgangItems: FeedItem[] = vorgaenge
|
||||
.filter((v) => {
|
||||
if (topicLabelSet.size === 0) return false
|
||||
return v.sachgebiet?.some((s) => topicLabelSet.has(s.toLowerCase())) ?? false
|
||||
})
|
||||
.map((v) => ({
|
||||
id: `vorgang-${v.id}`,
|
||||
kind: "vorgang" as const,
|
||||
status: "upcoming" as const,
|
||||
title: v.titel,
|
||||
url: null,
|
||||
date: v.datum ?? null,
|
||||
topics: (v.sachgebiet ?? []).map((s) => ({ label: s, url: null })),
|
||||
source: "DIP Bundestag",
|
||||
}))
|
||||
|
||||
const items = [...pollItems, ...vorgangItems]
|
||||
return items.sort((a, b) => {
|
||||
if (a.date && b.date) return b.date.localeCompare(a.date)
|
||||
if (!a.date && b.date) return 1
|
||||
if (a.date && !b.date) return -1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
}
|
||||
|
||||
function classifyPoll(poll: Poll): "upcoming" | "past" {
|
||||
if (poll.field_accepted != null) return "past"
|
||||
if (!poll.field_poll_date) return "upcoming"
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return poll.field_poll_date > today ? "upcoming" : "past"
|
||||
}
|
||||
|
||||
async function fetchPollsForPoliticians(politicianIDs: number[]): Promise<Poll[]> {
|
||||
if (politicianIDs.length === 0) return []
|
||||
|
||||
const mandateResults = await Promise.all(politicianIDs.map((pid) => fetchCandidacyMandates(pid)))
|
||||
const mandateIDs = mandateResults.flatMap((mandates) => mandates.slice(0, 3).map((m) => m.id))
|
||||
|
||||
const voteResults = await Promise.all(mandateIDs.map((mid) => fetchVotes(mid)))
|
||||
const pollIDSet = new Set<number>()
|
||||
for (const votes of voteResults) {
|
||||
for (const v of votes) {
|
||||
if (v.poll?.id != null) pollIDSet.add(v.poll.id)
|
||||
}
|
||||
}
|
||||
|
||||
return fetchPollsByIds(Array.from(pollIDSet))
|
||||
}
|
||||
15
src/features/bundestag/store.ts
Normal file
15
src/features/bundestag/store.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
interface BundestagUIState {
|
||||
topicSearch: string
|
||||
setTopicSearch: (query: string) => void
|
||||
politicianSearch: string
|
||||
setPoliticianSearch: (query: string) => void
|
||||
}
|
||||
|
||||
export const useBundestagUI = create<BundestagUIState>((set) => ({
|
||||
topicSearch: "",
|
||||
setTopicSearch: (query) => set({ topicSearch: query }),
|
||||
politicianSearch: "",
|
||||
setPoliticianSearch: (query) => set({ politicianSearch: query }),
|
||||
}))
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Chip } from "konsta/react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import type { FeedItem as FeedItemType } from "../lib/assemble-feed"
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
@@ -25,7 +25,7 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
|
||||
<h2 className="text-[15px] font-medium leading-snug">{item.title}</h2>
|
||||
)}
|
||||
{item.date && (
|
||||
<time dateTime={item.date} className="text-xs text-black/55 dark:text-white/55 whitespace-nowrap shrink-0">
|
||||
<time dateTime={item.date} className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
||||
{formatDate(item.date)}
|
||||
</time>
|
||||
)}
|
||||
@@ -35,15 +35,26 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
|
||||
{item.topics.map((topic) =>
|
||||
topic.url ? (
|
||||
<a key={topic.label} href={topic.url} target="_blank" rel="noopener noreferrer">
|
||||
<Chip className="cursor-pointer">{topic.label}</Chip>
|
||||
<Badge variant="secondary" className="cursor-pointer">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
</a>
|
||||
) : (
|
||||
<Chip key={topic.label}>{topic.label}</Chip>
|
||||
<Badge key={topic.label} variant="secondary">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-black/55 dark:text-white/55 mt-1">{item.source}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-xs text-muted-foreground">{item.source}</p>
|
||||
{item.kind === "vorgang" && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{item.status === "upcoming" ? "In Beratung" : "Abgeschlossen"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface FeedTopicRef {
|
||||
|
||||
export interface FeedItem {
|
||||
id: string
|
||||
kind: "poll"
|
||||
kind: "poll" | "vorgang"
|
||||
status: "upcoming" | "past"
|
||||
title: string
|
||||
url: string | null
|
||||
|
||||
@@ -7,16 +7,16 @@ export interface FeedCacheData {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export async function loadFeedCache(db: PGlite): Promise<FeedCacheData | null> {
|
||||
return loadCachedFeed(db)
|
||||
export async function loadFeedCache(db: PGlite, cacheKey?: string): Promise<FeedCacheData | null> {
|
||||
return loadCachedFeed(db, cacheKey)
|
||||
}
|
||||
|
||||
export async function saveFeedCache(db: PGlite, items: FeedItem[]): Promise<void> {
|
||||
await saveCachedFeed(db, items)
|
||||
export async function saveFeedCache(db: PGlite, items: FeedItem[], cacheKey?: string): Promise<void> {
|
||||
await saveCachedFeed(db, items, cacheKey)
|
||||
}
|
||||
|
||||
export async function clearFeedCache(db: PGlite): Promise<void> {
|
||||
await clearCachedFeed(db)
|
||||
export async function clearFeedCache(db: PGlite, cacheKey?: string): Promise<void> {
|
||||
await clearCachedFeed(db, cacheKey)
|
||||
}
|
||||
|
||||
export function mergeFeedItems(cached: FeedItem[], fresh: FeedItem[]): FeedItem[] {
|
||||
|
||||
8
src/features/home/components/home-page.tsx
Normal file
8
src/features/home/components/home-page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export function HomePage() {
|
||||
return (
|
||||
<div className="text-center mt-12 px-4">
|
||||
<p className="text-lg font-medium">Willkommen</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Verfolge Abstimmungen im Bundestag und deinem Landtag.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
src/features/home/index.ts
Normal file
1
src/features/home/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HomePage } from "./components/home-page"
|
||||
52
src/features/landtag/components/landtag-configure.tsx
Normal file
52
src/features/landtag/components/landtag-configure.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RepresentativeList } from "@/shared/components/representative-list"
|
||||
import { useDb } from "@/shared/db/provider"
|
||||
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { loadCachedResult } from "../../location/lib/geo"
|
||||
import { useLandtagUI } from "../store"
|
||||
|
||||
export function LandtagConfigure() {
|
||||
const db = useDb()
|
||||
const search = useLandtagUI((s) => s.politicianSearch)
|
||||
const setSearch = useLandtagUI((s) => s.setPoliticianSearch)
|
||||
const [mandates, setMandates] = useState<MandateWithPolitician[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadCachedResult(db).then((cached) => {
|
||||
if (cached) setMandates(cached.mandates)
|
||||
setLoaded(true)
|
||||
})
|
||||
}, [db])
|
||||
|
||||
if (loaded && mandates.length === 0) {
|
||||
return (
|
||||
<div className="text-center mt-12 px-4">
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
Noch keine Abgeordneten geladen. Erkenne zuerst deinen Standort in den Einstellungen.
|
||||
</p>
|
||||
<Link to="/app/settings" className="text-primary text-sm underline">
|
||||
Zu den Einstellungen
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<section>
|
||||
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Abgeordnete
|
||||
</h2>
|
||||
{!loaded ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<RepresentativeList mandates={mandates} searchQuery={search} onSearchChange={setSearch} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
src/features/landtag/components/landtag-page.tsx
Normal file
55
src/features/landtag/components/landtag-page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { RepresentativeList } from "@/shared/components/representative-list"
|
||||
import { useDb } from "@/shared/db/provider"
|
||||
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { type GeoResult, loadCachedResult } from "../../location/lib/geo"
|
||||
import { useLandtagUI } from "../store"
|
||||
|
||||
export function LandtagPage() {
|
||||
const db = useDb()
|
||||
const [result, setResult] = useState<GeoResult | null>(null)
|
||||
const [mandates, setMandates] = useState<MandateWithPolitician[]>([])
|
||||
const search = useLandtagUI((s) => s.politicianSearch)
|
||||
const setSearch = useLandtagUI((s) => s.setPoliticianSearch)
|
||||
|
||||
useEffect(() => {
|
||||
loadCachedResult(db).then((cached) => {
|
||||
if (cached) {
|
||||
setResult(cached)
|
||||
setMandates(cached.mandates)
|
||||
}
|
||||
})
|
||||
}, [db])
|
||||
|
||||
if (!result || mandates.length === 0) {
|
||||
return (
|
||||
<div className="text-center mt-12 px-4">
|
||||
<p className="text-lg font-medium">Landtag</p>
|
||||
<p className="text-muted-foreground text-sm mt-2 mb-4">
|
||||
Erkenne zuerst deinen Standort in den Einstellungen, um deine Landtagsabgeordneten zu sehen.
|
||||
</p>
|
||||
<Link to="/app/settings" className="text-primary text-sm underline">
|
||||
Zu den Einstellungen
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
{result.landtag_label && (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<p className="text-sm font-medium">{result.landtag_label}</p>
|
||||
{result.bundesland && <p className="text-xs text-muted-foreground">{result.bundesland}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RepresentativeList mandates={mandates} searchQuery={search} onSearchChange={setSearch} />
|
||||
|
||||
<div className="px-4 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">Abstimmungsdaten folgen in Kürze</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
src/features/landtag/index.ts
Normal file
2
src/features/landtag/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LandtagPage } from "./components/landtag-page"
|
||||
export { LandtagConfigure } from "./components/landtag-configure"
|
||||
11
src/features/landtag/store.ts
Normal file
11
src/features/landtag/store.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
interface LandtagUIState {
|
||||
politicianSearch: string
|
||||
setPoliticianSearch: (query: string) => void
|
||||
}
|
||||
|
||||
export const useLandtagUI = create<LandtagUIState>((set) => ({
|
||||
politicianSearch: "",
|
||||
setPoliticianSearch: (query) => set({ politicianSearch: query }),
|
||||
}))
|
||||
@@ -1,3 +1,9 @@
|
||||
export { loadCachedResult, clearGeoCache, detectFromCoords } from "./lib/geo"
|
||||
export {
|
||||
loadCachedResult,
|
||||
clearGeoCache,
|
||||
detectFromCoords,
|
||||
loadBundestagMandates,
|
||||
fetchAndCacheBundestagMandates,
|
||||
} from "./lib/geo"
|
||||
export type { GeoResult } from "./lib/geo"
|
||||
export { getPartyMeta } from "./lib/parties"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
saveGeoCache,
|
||||
} from "@/shared/db/geo-cache-db"
|
||||
import { type MandateWithPolitician, fetchMandatesByParliamentPeriod } from "@/shared/lib/aw-api"
|
||||
import { BUNDESTAG_LEGISLATURE_ID } from "@/shared/lib/constants"
|
||||
import type { PGlite } from "@electric-sql/pglite"
|
||||
|
||||
const BUNDESLAND_TO_PARLIAMENT: Record<string, { label: string; parliamentPeriodId: number }> = {
|
||||
@@ -56,6 +57,34 @@ export async function clearGeoCache(db: PGlite): Promise<void> {
|
||||
await clearGeoCacheDb(db)
|
||||
}
|
||||
|
||||
const BUNDESTAG_CACHE_KEY = "Bundestag"
|
||||
|
||||
/** Load cached Bundestag mandates from geo_cache. */
|
||||
export async function loadBundestagMandates(db: PGlite): Promise<MandateWithPolitician[] | null> {
|
||||
const cached = await loadGeoCache(db, BUNDESTAG_CACHE_KEY)
|
||||
if (!cached) return null
|
||||
return (cached as unknown as { mandates: MandateWithPolitician[] }).mandates
|
||||
}
|
||||
|
||||
/** Fetch Bundestag mandates from API and cache them. */
|
||||
export async function fetchAndCacheBundestagMandates(db: PGlite): Promise<MandateWithPolitician[]> {
|
||||
const cached = await loadBundestagMandates(db)
|
||||
if (cached) return cached
|
||||
|
||||
let mandates: MandateWithPolitician[] = []
|
||||
try {
|
||||
mandates = await fetchMandatesByParliamentPeriod(BUNDESTAG_LEGISLATURE_ID)
|
||||
} catch {
|
||||
mandates = []
|
||||
}
|
||||
|
||||
if (mandates.length > 0) {
|
||||
await saveGeoCache(db, BUNDESTAG_CACHE_KEY, { mandates } as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
return mandates
|
||||
}
|
||||
|
||||
export async function detectFromCoords(db: PGlite, lat: number, lon: number, skipCache = false): Promise<GeoResult> {
|
||||
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`
|
||||
const res = await fetch(url, {
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useDb } from "@/shared/db/provider"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Block, Button, Card, List, ListItem, Navbar, Searchbar } from "konsta/react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { type GeoResult, loadCachedResult } from "../../location/lib/geo"
|
||||
import { getPartyMeta } from "../../location/lib/parties"
|
||||
import { usePoliticiansUI } from "../store"
|
||||
|
||||
interface PartyGroup {
|
||||
partyLabel: string
|
||||
members: MandateWithPolitician[]
|
||||
}
|
||||
|
||||
/** Extract party/fraction label for grouping. Prefers party, falls back to current fraction. */
|
||||
function partyLabel(m: MandateWithPolitician): string {
|
||||
if (m.party?.label) return m.party.label
|
||||
const current = m.fraction_membership?.find((f) => !f.valid_until)
|
||||
if (current) return current.fraction.label.replace(/\s*\([^)]+\)\s*$/, "")
|
||||
return "parteilos"
|
||||
}
|
||||
|
||||
function groupByParty(mandates: MandateWithPolitician[]): PartyGroup[] {
|
||||
const map = new Map<string, MandateWithPolitician[]>()
|
||||
for (const m of mandates) {
|
||||
const key = partyLabel(m)
|
||||
const list = map.get(key)
|
||||
if (list) {
|
||||
list.push(m)
|
||||
} else {
|
||||
map.set(key, [m])
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.map(([partyLabel, members]) => ({
|
||||
partyLabel,
|
||||
members: members.sort((a, b) => a.politician.label.localeCompare(b.politician.label)),
|
||||
}))
|
||||
}
|
||||
|
||||
function mandateFunction(m: MandateWithPolitician): string | null {
|
||||
const won = m.electoral_data?.mandate_won
|
||||
const constituency = m.electoral_data?.constituency?.label
|
||||
if (!won) return null
|
||||
if (won === "constituency" && constituency) {
|
||||
const clean = constituency.replace(/\s*\([^)]+\)\s*$/, "")
|
||||
return `Wahlkreis ${clean}`
|
||||
}
|
||||
if (won === "list") return "Landesliste"
|
||||
if (won === "moved_up") return "Nachgerückt"
|
||||
return won
|
||||
}
|
||||
|
||||
export function PoliticianSearch() {
|
||||
const db = useDb()
|
||||
const [result, setResult] = useState<GeoResult | null>(null)
|
||||
const search = usePoliticiansUI((s) => s.searchQuery)
|
||||
const setSearch = usePoliticiansUI((s) => s.setSearchQuery)
|
||||
const { isFollowing, follow, unfollow } = useFollows()
|
||||
|
||||
useEffect(() => {
|
||||
loadCachedResult(db).then((cached) => {
|
||||
if (cached) setResult(cached)
|
||||
})
|
||||
}, [db])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!result) return []
|
||||
const filtered = search
|
||||
? result.mandates.filter((m) => m.politician.label.toLowerCase().includes(search.toLowerCase()))
|
||||
: result.mandates
|
||||
return groupByParty(filtered)
|
||||
}, [result, search])
|
||||
|
||||
if (!result || result.mandates.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Navbar title="Abgeordnete" />
|
||||
<Block className="text-center mt-12">
|
||||
<p className="text-black/55 dark:text-white/55 text-sm mb-4">
|
||||
Noch keine Abgeordneten geladen. Erkenne zuerst deinen Standort in den Einstellungen.
|
||||
</p>
|
||||
<Link to="/app/settings" className="text-primary text-sm underline">
|
||||
Zu den Einstellungen
|
||||
</Link>
|
||||
</Block>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar title="Abgeordnete" />
|
||||
|
||||
<Searchbar
|
||||
value={search}
|
||||
onInput={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch("")}
|
||||
placeholder="Name filtern…"
|
||||
/>
|
||||
|
||||
<div className="px-4 pb-20 space-y-3">
|
||||
{groups.map((group) => {
|
||||
const meta = getPartyMeta(group.partyLabel)
|
||||
return (
|
||||
<Card key={group.partyLabel} outline contentWrap={false}>
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-2.5"
|
||||
style={{ borderLeftWidth: 4, borderLeftColor: meta.color }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-full text-[10px] font-bold text-white shrink-0"
|
||||
style={{ backgroundColor: meta.color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{meta.short.slice(0, 3)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{group.partyLabel}</span>
|
||||
<span className="text-xs text-black/55 dark:text-white/55 ml-auto">{group.members.length}</span>
|
||||
</div>
|
||||
<List nested dividers>
|
||||
{group.members.map((m) => {
|
||||
const followed = isFollowing("politician", m.politician.id)
|
||||
const fn = mandateFunction(m)
|
||||
return (
|
||||
<ListItem
|
||||
key={m.id}
|
||||
title={m.politician.label}
|
||||
subtitle={fn}
|
||||
after={
|
||||
<Button
|
||||
small
|
||||
rounded
|
||||
outline={!followed}
|
||||
onClick={() =>
|
||||
followed
|
||||
? unfollow("politician", m.politician.id)
|
||||
: follow("politician", m.politician.id, m.politician.label)
|
||||
}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`}
|
||||
>
|
||||
{followed ? "Folgst du" : "Folgen"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{groups.length === 0 && search && (
|
||||
<p className="text-center text-sm text-black/55 dark:text-white/55 py-6">Keine Abgeordneten für „{search}"</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { PoliticianSearch } from "./components/politician-search"
|
||||
@@ -1,11 +0,0 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
interface PoliticiansUIState {
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
}
|
||||
|
||||
export const usePoliticiansUI = create<PoliticiansUIState>((set) => ({
|
||||
searchQuery: "",
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
}))
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { Button, List, ListItem, Navbar, Preloader, Searchbar } from "konsta/react"
|
||||
import { useTopics } from "../hooks/use-topics"
|
||||
import { useTopicsUI } from "../store"
|
||||
|
||||
export function TopicList() {
|
||||
const { topics, loading, error } = useTopics()
|
||||
const { isFollowing, follow, unfollow } = useFollows()
|
||||
const search = useTopicsUI((s) => s.searchQuery)
|
||||
const setSearch = useTopicsUI((s) => s.setSearchQuery)
|
||||
|
||||
const filtered = topics.filter((t) => t.label.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar title="Themen" />
|
||||
|
||||
<Searchbar
|
||||
value={search}
|
||||
onInput={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch("")}
|
||||
placeholder="Themen suchen…"
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Preloader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 text-red-500" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<List dividers className="pb-20">
|
||||
{filtered.map((topic) => {
|
||||
const followed = isFollowing("topic", topic.id)
|
||||
return (
|
||||
<ListItem
|
||||
key={topic.id}
|
||||
title={topic.label}
|
||||
after={
|
||||
<Button
|
||||
small
|
||||
rounded
|
||||
outline={!followed}
|
||||
onClick={() => (followed ? unfollow("topic", topic.id) : follow("topic", topic.id, topic.label))}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `${topic.label} entfolgen` : `${topic.label} folgen`}
|
||||
>
|
||||
{followed ? "Folgst du" : "Folgen"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { TopicList } from "./components/topic-list"
|
||||
@@ -1,11 +0,0 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
interface TopicsUIState {
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
}
|
||||
|
||||
export const useTopicsUI = create<TopicsUIState>((set) => ({
|
||||
searchQuery: "",
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
}))
|
||||
277
src/routeTree.gen.ts
Normal file
277
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AppRouteRouteImport } from './routes/app/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AppSettingsRouteImport } from './routes/app/settings'
|
||||
import { Route as AppHomeRouteImport } from './routes/app/home'
|
||||
import { Route as AppLandtagRouteRouteImport } from './routes/app/landtag/route'
|
||||
import { Route as AppBundestagRouteRouteImport } from './routes/app/bundestag/route'
|
||||
import { Route as AppLandtagIndexRouteImport } from './routes/app/landtag/index'
|
||||
import { Route as AppBundestagIndexRouteImport } from './routes/app/bundestag/index'
|
||||
import { Route as AppLandtagConfigureRouteImport } from './routes/app/landtag/configure'
|
||||
import { Route as AppBundestagConfigureRouteImport } from './routes/app/bundestag/configure'
|
||||
|
||||
const AppRouteRoute = AppRouteRouteImport.update({
|
||||
id: '/app',
|
||||
path: '/app',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AppSettingsRoute = AppSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const AppHomeRoute = AppHomeRouteImport.update({
|
||||
id: '/home',
|
||||
path: '/home',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const AppLandtagRouteRoute = AppLandtagRouteRouteImport.update({
|
||||
id: '/landtag',
|
||||
path: '/landtag',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const AppBundestagRouteRoute = AppBundestagRouteRouteImport.update({
|
||||
id: '/bundestag',
|
||||
path: '/bundestag',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const AppLandtagIndexRoute = AppLandtagIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AppLandtagRouteRoute,
|
||||
} as any)
|
||||
const AppBundestagIndexRoute = AppBundestagIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AppBundestagRouteRoute,
|
||||
} as any)
|
||||
const AppLandtagConfigureRoute = AppLandtagConfigureRouteImport.update({
|
||||
id: '/configure',
|
||||
path: '/configure',
|
||||
getParentRoute: () => AppLandtagRouteRoute,
|
||||
} as any)
|
||||
const AppBundestagConfigureRoute = AppBundestagConfigureRouteImport.update({
|
||||
id: '/configure',
|
||||
path: '/configure',
|
||||
getParentRoute: () => AppBundestagRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/app': typeof AppRouteRouteWithChildren
|
||||
'/app/bundestag': typeof AppBundestagRouteRouteWithChildren
|
||||
'/app/landtag': typeof AppLandtagRouteRouteWithChildren
|
||||
'/app/home': typeof AppHomeRoute
|
||||
'/app/settings': typeof AppSettingsRoute
|
||||
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
|
||||
'/app/landtag/configure': typeof AppLandtagConfigureRoute
|
||||
'/app/bundestag/': typeof AppBundestagIndexRoute
|
||||
'/app/landtag/': typeof AppLandtagIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/app': typeof AppRouteRouteWithChildren
|
||||
'/app/home': typeof AppHomeRoute
|
||||
'/app/settings': typeof AppSettingsRoute
|
||||
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
|
||||
'/app/landtag/configure': typeof AppLandtagConfigureRoute
|
||||
'/app/bundestag': typeof AppBundestagIndexRoute
|
||||
'/app/landtag': typeof AppLandtagIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/app': typeof AppRouteRouteWithChildren
|
||||
'/app/bundestag': typeof AppBundestagRouteRouteWithChildren
|
||||
'/app/landtag': typeof AppLandtagRouteRouteWithChildren
|
||||
'/app/home': typeof AppHomeRoute
|
||||
'/app/settings': typeof AppSettingsRoute
|
||||
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
|
||||
'/app/landtag/configure': typeof AppLandtagConfigureRoute
|
||||
'/app/bundestag/': typeof AppBundestagIndexRoute
|
||||
'/app/landtag/': typeof AppLandtagIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/app'
|
||||
| '/app/bundestag'
|
||||
| '/app/landtag'
|
||||
| '/app/home'
|
||||
| '/app/settings'
|
||||
| '/app/bundestag/configure'
|
||||
| '/app/landtag/configure'
|
||||
| '/app/bundestag/'
|
||||
| '/app/landtag/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/app'
|
||||
| '/app/home'
|
||||
| '/app/settings'
|
||||
| '/app/bundestag/configure'
|
||||
| '/app/landtag/configure'
|
||||
| '/app/bundestag'
|
||||
| '/app/landtag'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/app'
|
||||
| '/app/bundestag'
|
||||
| '/app/landtag'
|
||||
| '/app/home'
|
||||
| '/app/settings'
|
||||
| '/app/bundestag/configure'
|
||||
| '/app/landtag/configure'
|
||||
| '/app/bundestag/'
|
||||
| '/app/landtag/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AppRouteRoute: typeof AppRouteRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/app': {
|
||||
id: '/app'
|
||||
path: '/app'
|
||||
fullPath: '/app'
|
||||
preLoaderRoute: typeof AppRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/app/settings': {
|
||||
id: '/app/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/app/settings'
|
||||
preLoaderRoute: typeof AppSettingsRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/app/home': {
|
||||
id: '/app/home'
|
||||
path: '/home'
|
||||
fullPath: '/app/home'
|
||||
preLoaderRoute: typeof AppHomeRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/app/landtag': {
|
||||
id: '/app/landtag'
|
||||
path: '/landtag'
|
||||
fullPath: '/app/landtag'
|
||||
preLoaderRoute: typeof AppLandtagRouteRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/app/bundestag': {
|
||||
id: '/app/bundestag'
|
||||
path: '/bundestag'
|
||||
fullPath: '/app/bundestag'
|
||||
preLoaderRoute: typeof AppBundestagRouteRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/app/landtag/': {
|
||||
id: '/app/landtag/'
|
||||
path: '/'
|
||||
fullPath: '/app/landtag/'
|
||||
preLoaderRoute: typeof AppLandtagIndexRouteImport
|
||||
parentRoute: typeof AppLandtagRouteRoute
|
||||
}
|
||||
'/app/bundestag/': {
|
||||
id: '/app/bundestag/'
|
||||
path: '/'
|
||||
fullPath: '/app/bundestag/'
|
||||
preLoaderRoute: typeof AppBundestagIndexRouteImport
|
||||
parentRoute: typeof AppBundestagRouteRoute
|
||||
}
|
||||
'/app/landtag/configure': {
|
||||
id: '/app/landtag/configure'
|
||||
path: '/configure'
|
||||
fullPath: '/app/landtag/configure'
|
||||
preLoaderRoute: typeof AppLandtagConfigureRouteImport
|
||||
parentRoute: typeof AppLandtagRouteRoute
|
||||
}
|
||||
'/app/bundestag/configure': {
|
||||
id: '/app/bundestag/configure'
|
||||
path: '/configure'
|
||||
fullPath: '/app/bundestag/configure'
|
||||
preLoaderRoute: typeof AppBundestagConfigureRouteImport
|
||||
parentRoute: typeof AppBundestagRouteRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AppBundestagRouteRouteChildren {
|
||||
AppBundestagConfigureRoute: typeof AppBundestagConfigureRoute
|
||||
AppBundestagIndexRoute: typeof AppBundestagIndexRoute
|
||||
}
|
||||
|
||||
const AppBundestagRouteRouteChildren: AppBundestagRouteRouteChildren = {
|
||||
AppBundestagConfigureRoute: AppBundestagConfigureRoute,
|
||||
AppBundestagIndexRoute: AppBundestagIndexRoute,
|
||||
}
|
||||
|
||||
const AppBundestagRouteRouteWithChildren =
|
||||
AppBundestagRouteRoute._addFileChildren(AppBundestagRouteRouteChildren)
|
||||
|
||||
interface AppLandtagRouteRouteChildren {
|
||||
AppLandtagConfigureRoute: typeof AppLandtagConfigureRoute
|
||||
AppLandtagIndexRoute: typeof AppLandtagIndexRoute
|
||||
}
|
||||
|
||||
const AppLandtagRouteRouteChildren: AppLandtagRouteRouteChildren = {
|
||||
AppLandtagConfigureRoute: AppLandtagConfigureRoute,
|
||||
AppLandtagIndexRoute: AppLandtagIndexRoute,
|
||||
}
|
||||
|
||||
const AppLandtagRouteRouteWithChildren = AppLandtagRouteRoute._addFileChildren(
|
||||
AppLandtagRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface AppRouteRouteChildren {
|
||||
AppBundestagRouteRoute: typeof AppBundestagRouteRouteWithChildren
|
||||
AppLandtagRouteRoute: typeof AppLandtagRouteRouteWithChildren
|
||||
AppHomeRoute: typeof AppHomeRoute
|
||||
AppSettingsRoute: typeof AppSettingsRoute
|
||||
}
|
||||
|
||||
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
||||
AppBundestagRouteRoute: AppBundestagRouteRouteWithChildren,
|
||||
AppLandtagRouteRoute: AppLandtagRouteRouteWithChildren,
|
||||
AppHomeRoute: AppHomeRoute,
|
||||
AppSettingsRoute: AppSettingsRoute,
|
||||
}
|
||||
|
||||
const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
|
||||
AppRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AppRouteRoute: AppRouteRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
6
src/routes/app/bundestag/configure.tsx
Normal file
6
src/routes/app/bundestag/configure.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { BundestagConfigure } from "@/features/bundestag"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/bundestag/configure")({
|
||||
component: BundestagConfigure,
|
||||
})
|
||||
6
src/routes/app/bundestag/index.tsx
Normal file
6
src/routes/app/bundestag/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { BundestagFeed } from "@/features/bundestag"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/bundestag/")({
|
||||
component: BundestagFeed,
|
||||
})
|
||||
5
src/routes/app/bundestag/route.tsx
Normal file
5
src/routes/app/bundestag/route.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/bundestag")({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { FeedList, useFeed } from "@/features/feed"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Navbar } from "konsta/react"
|
||||
|
||||
function formatCacheAge(timestamp: number): string {
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 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 FeedPage() {
|
||||
const { items, loading, refreshing, error, lastUpdated, refresh } = useFeed()
|
||||
const hasItems = items.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar
|
||||
title="Feed"
|
||||
right={
|
||||
lastUpdated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refresh({ silent: true })}
|
||||
disabled={refreshing}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors disabled:opacity-50"
|
||||
aria-label="Feed aktualisieren"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`w-5 h-5 text-muted-foreground ${refreshing ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
subtitle={lastUpdated ? `Aktualisiert ${formatCacheAge(lastUpdated)}` : undefined}
|
||||
/>
|
||||
|
||||
<div className="pb-20">
|
||||
{/* Loading spinner — only when no cached items */}
|
||||
{loading && !hasItems && (
|
||||
<output className="flex items-center justify-center h-48" aria-label="Feed wird geladen">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</output>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 text-destructive" role="alert">
|
||||
<p className="font-semibold">Fehler beim Laden</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed list */}
|
||||
{hasItems && <FeedList items={items} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/app/feed")({
|
||||
component: FeedPage,
|
||||
})
|
||||
6
src/routes/app/home.tsx
Normal file
6
src/routes/app/home.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { HomePage } from "@/features/home"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/home")({
|
||||
component: HomePage,
|
||||
})
|
||||
6
src/routes/app/landtag/configure.tsx
Normal file
6
src/routes/app/landtag/configure.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { LandtagConfigure } from "@/features/landtag"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/landtag/configure")({
|
||||
component: LandtagConfigure,
|
||||
})
|
||||
6
src/routes/app/landtag/index.tsx
Normal file
6
src/routes/app/landtag/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { LandtagPage } from "@/features/landtag"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/landtag/")({
|
||||
component: LandtagPage,
|
||||
})
|
||||
5
src/routes/app/landtag/route.tsx
Normal file
5
src/routes/app/landtag/route.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/landtag")({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { PoliticianSearch } from "@/features/politicians"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/politicians")({
|
||||
component: PoliticianSearch,
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DbProvider } from "@/shared/db/provider"
|
||||
import { Outlet, createFileRoute, useMatches, useNavigate } from "@tanstack/react-router"
|
||||
import { App, Page, Tabbar, TabbarLink } from "konsta/react"
|
||||
import { Link, Outlet, createFileRoute, useMatches, useNavigate } from "@tanstack/react-router"
|
||||
import { Suspense } from "react"
|
||||
|
||||
interface TabDef {
|
||||
@@ -11,18 +10,18 @@ interface TabDef {
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{
|
||||
to: "/app/feed",
|
||||
label: "Feed",
|
||||
to: "/app/home",
|
||||
label: "Home",
|
||||
icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1m-2 0h2",
|
||||
},
|
||||
{
|
||||
to: "/app/bundestag",
|
||||
label: "Bundestag",
|
||||
icon: "M3 7h18M3 12h18M3 17h18",
|
||||
},
|
||||
{
|
||||
to: "/app/topics",
|
||||
label: "Themen",
|
||||
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
|
||||
},
|
||||
{
|
||||
to: "/app/politicians",
|
||||
label: "Abgeordnete",
|
||||
to: "/app/landtag",
|
||||
label: "Landtag",
|
||||
icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
},
|
||||
{
|
||||
@@ -36,7 +35,7 @@ function TabIcon({ d }: { d: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-6 h-6"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -51,47 +50,115 @@ function TabIcon({ d }: { d: string }) {
|
||||
function AppLayout() {
|
||||
const navigate = useNavigate()
|
||||
const matches = useMatches()
|
||||
const currentPath = matches.at(-1)?.pathname ?? "/app/feed"
|
||||
const currentPath = matches.at(-1)?.pathname ?? "/app/home"
|
||||
const currentTab = TABS.find((t) => currentPath.startsWith(t.to)) ?? TABS[0]
|
||||
const isConfigureRoute = currentPath.endsWith("/configure")
|
||||
const isBundestag = currentPath.startsWith("/app/bundestag")
|
||||
const isLandtag = currentPath.startsWith("/app/landtag")
|
||||
|
||||
// Determine parent path for back navigation from configure routes
|
||||
const parentPath = isConfigureRoute ? currentPath.replace(/\/configure$/, "") : null
|
||||
|
||||
// Determine configure link target for typed navigation
|
||||
const configureTarget = isBundestag ? "/app/bundestag/configure" : isLandtag ? "/app/landtag/configure" : null
|
||||
|
||||
return (
|
||||
<App theme="ios" safeAreas className="max-w-lg mx-auto">
|
||||
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
||||
<header className="flex items-center px-4 py-3 bg-card border-b border-border shadow-sm safe-area-top">
|
||||
{isConfigureRoute && parentPath ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: parentPath })}
|
||||
className="flex items-center gap-1 text-primary"
|
||||
aria-label="Zurück"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span className="text-sm">Zurück</span>
|
||||
</button>
|
||||
) : null}
|
||||
<h1 className={`text-base font-semibold text-card-foreground ${isConfigureRoute ? "ml-2" : ""}`}>
|
||||
{isConfigureRoute ? `${currentTab.label} konfigurieren` : currentTab.label}
|
||||
</h1>
|
||||
{!isConfigureRoute && configureTarget && (
|
||||
<Link
|
||||
to={configureTarget}
|
||||
className="ml-auto p-1.5 rounded-md hover:bg-muted transition-colors"
|
||||
aria-label={`${currentTab.label} konfigurieren`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-5 h-5 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<Page>
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
</Page>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DbProvider>
|
||||
<Page>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
<Tabbar labels icons className="left-0 bottom-0 fixed">
|
||||
{TABS.map((tab) => (
|
||||
<TabbarLink
|
||||
key={tab.to}
|
||||
active={currentPath === tab.to}
|
||||
onClick={() => {
|
||||
const go = () => navigate({ to: tab.to })
|
||||
if (
|
||||
document.startViewTransition &&
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
document.startViewTransition(go)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
}}
|
||||
icon={<TabIcon d={tab.icon} />}
|
||||
label={tab.label}
|
||||
/>
|
||||
))}
|
||||
</Tabbar>
|
||||
</Page>
|
||||
</div>
|
||||
</DbProvider>
|
||||
</Suspense>
|
||||
</App>
|
||||
|
||||
<nav className="flex bg-card border-t border-border safe-area-bottom" role="tablist" aria-label="Hauptnavigation">
|
||||
{TABS.map((tab) => {
|
||||
const active = currentPath.startsWith(tab.to)
|
||||
return (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-label={tab.label}
|
||||
onClick={(e) => {
|
||||
if (active) return
|
||||
e.preventDefault()
|
||||
const go = () => navigate({ to: tab.to })
|
||||
if (document.startViewTransition && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
document.startViewTransition(go)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
}}
|
||||
className={`flex flex-col items-center justify-center flex-1 py-2 gap-0.5 transition-colors no-underline ${
|
||||
active ? "text-primary" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<TabIcon d={tab.icon} />
|
||||
<span className="text-[10px] font-medium">{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { TopicList } from "@/features/topics"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/topics")({
|
||||
component: TopicList,
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: () => <Navigate to="/app/feed" />,
|
||||
component: () => <Navigate to="/app/home" />,
|
||||
})
|
||||
|
||||
136
src/shared/components/representative-list.tsx
Normal file
136
src/shared/components/representative-list.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { getPartyMeta } from "@/features/location/lib/parties"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
||||
import { Button } from "./ui/button"
|
||||
import { Card, CardContent } from "./ui/card"
|
||||
import { Input } from "./ui/input"
|
||||
|
||||
export interface PartyGroup {
|
||||
partyLabel: string
|
||||
members: MandateWithPolitician[]
|
||||
}
|
||||
|
||||
/** Extract party/fraction label for grouping. Prefers party, falls back to current fraction. */
|
||||
function partyLabel(m: MandateWithPolitician): string {
|
||||
if (m.party?.label) return m.party.label
|
||||
const current = m.fraction_membership?.find((f) => !f.valid_until)
|
||||
if (current) return current.fraction.label.replace(/\s*\([^)]+\)\s*$/, "")
|
||||
return "parteilos"
|
||||
}
|
||||
|
||||
export function groupByParty(mandates: MandateWithPolitician[]): PartyGroup[] {
|
||||
const map = new Map<string, MandateWithPolitician[]>()
|
||||
for (const m of mandates) {
|
||||
const key = partyLabel(m)
|
||||
const list = map.get(key)
|
||||
if (list) {
|
||||
list.push(m)
|
||||
} else {
|
||||
map.set(key, [m])
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.map(([partyLabel, members]) => ({
|
||||
partyLabel,
|
||||
members: members.sort((a, b) => a.politician.label.localeCompare(b.politician.label)),
|
||||
}))
|
||||
}
|
||||
|
||||
export function mandateFunction(m: MandateWithPolitician): string | null {
|
||||
const won = m.electoral_data?.mandate_won
|
||||
const constituency = m.electoral_data?.constituency?.label
|
||||
if (!won) return null
|
||||
if (won === "constituency" && constituency) {
|
||||
const clean = constituency.replace(/\s*\([^)]+\)\s*$/, "")
|
||||
return `Wahlkreis ${clean}`
|
||||
}
|
||||
if (won === "list") return "Landesliste"
|
||||
if (won === "moved_up") return "Nachgerückt"
|
||||
return won
|
||||
}
|
||||
|
||||
interface RepresentativeListProps {
|
||||
mandates: MandateWithPolitician[]
|
||||
userBundesland?: string | null
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
}
|
||||
|
||||
export function RepresentativeList({ mandates, searchQuery, onSearchChange }: RepresentativeListProps) {
|
||||
const { isFollowing, follow, unfollow } = useFollows()
|
||||
|
||||
const filtered = searchQuery
|
||||
? mandates.filter((m) => m.politician.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: mandates
|
||||
const groups = groupByParty(filtered)
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<div className="px-4 py-3">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Name filtern…"
|
||||
type="search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 space-y-3">
|
||||
{groups.map((group) => {
|
||||
const meta = getPartyMeta(group.partyLabel)
|
||||
return (
|
||||
<Card key={group.partyLabel} className="py-0 gap-0 overflow-hidden">
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-2.5"
|
||||
style={{ borderLeftWidth: 4, borderLeftColor: meta.color }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-full text-[10px] font-bold text-white shrink-0"
|
||||
style={{ backgroundColor: meta.color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{meta.short.slice(0, 3)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{group.partyLabel}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{group.members.length}</span>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{group.members.map((m) => {
|
||||
const followed = isFollowing("politician", m.politician.id)
|
||||
const fn = mandateFunction(m)
|
||||
return (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-2.5">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{m.politician.label}</p>
|
||||
{fn && <p className="text-xs text-muted-foreground">{fn}</p>}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={followed ? "default" : "outline"}
|
||||
onClick={() =>
|
||||
followed
|
||||
? unfollow("politician", m.politician.id)
|
||||
: follow("politician", m.politician.id, m.politician.label)
|
||||
}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`}
|
||||
>
|
||||
{followed ? "Folgst du" : "Folgen"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{groups.length === 0 && searchQuery && (
|
||||
<p className="text-center text-sm text-muted-foreground py-6">Keine Abgeordneten für „{searchQuery}"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
src/shared/components/topic-toggle-list.tsx
Normal file
61
src/shared/components/topic-toggle-list.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTopics } from "@/features/topics/hooks/use-topics"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
|
||||
interface TopicToggleListProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
}
|
||||
|
||||
export function TopicToggleList({ searchQuery, onSearchChange }: TopicToggleListProps) {
|
||||
const { topics, loading, error } = useTopics()
|
||||
const { isFollowing, follow, unfollow } = useFollows()
|
||||
|
||||
const filtered = topics.filter((t) => t.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<div className="px-4 py-3">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Themen suchen…"
|
||||
type="search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{filtered.map((topic) => {
|
||||
const followed = isFollowing("topic", topic.id)
|
||||
return (
|
||||
<div key={topic.id} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">{topic.label}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={followed ? "default" : "outline"}
|
||||
onClick={() => (followed ? unfollow("topic", topic.id) : follow("topic", topic.id, topic.label))}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `${topic.label} entfolgen` : `${topic.label} folgen`}
|
||||
>
|
||||
{followed ? "Folgst du" : "Folgen"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { FeedItem } from "@/features/feed/lib/assemble-feed"
|
||||
import type { PGlite } from "@electric-sql/pglite"
|
||||
|
||||
const CACHE_KEY = "feed_items"
|
||||
const DEFAULT_CACHE_KEY = "feed_items"
|
||||
|
||||
interface CacheRow {
|
||||
data: { items: FeedItem[] }
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function loadCachedFeed(db: PGlite): Promise<{ items: FeedItem[]; updatedAt: number } | null> {
|
||||
const res = await db.query<CacheRow>("SELECT data, updated_at FROM feed_cache WHERE id = $1", [CACHE_KEY])
|
||||
export async function loadCachedFeed(
|
||||
db: PGlite,
|
||||
cacheKey = DEFAULT_CACHE_KEY,
|
||||
): Promise<{ items: FeedItem[]; updatedAt: number } | null> {
|
||||
const res = await db.query<CacheRow>("SELECT data, updated_at FROM feed_cache WHERE id = $1", [cacheKey])
|
||||
if (res.rows.length === 0) return null
|
||||
const row = res.rows[0]
|
||||
return {
|
||||
@@ -18,14 +21,14 @@ export async function loadCachedFeed(db: PGlite): Promise<{ items: FeedItem[]; u
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCachedFeed(db: PGlite, items: FeedItem[]): Promise<void> {
|
||||
export async function saveCachedFeed(db: PGlite, items: FeedItem[], cacheKey = DEFAULT_CACHE_KEY): Promise<void> {
|
||||
await db.query(
|
||||
`INSERT INTO feed_cache (id, data, updated_at) VALUES ($1, $2, now())
|
||||
ON CONFLICT (id) DO UPDATE SET data = $2, updated_at = now()`,
|
||||
[CACHE_KEY, JSON.stringify({ items })],
|
||||
[cacheKey, JSON.stringify({ items })],
|
||||
)
|
||||
}
|
||||
|
||||
export async function clearCachedFeed(db: PGlite): Promise<void> {
|
||||
await db.query("DELETE FROM feed_cache WHERE id = $1", [CACHE_KEY])
|
||||
export async function clearCachedFeed(db: PGlite, cacheKey = DEFAULT_CACHE_KEY): Promise<void> {
|
||||
await db.query("DELETE FROM feed_cache WHERE id = $1", [cacheKey])
|
||||
}
|
||||
|
||||
@@ -165,6 +165,19 @@ export function fetchVotes(mandateID: number): Promise<Vote[]> {
|
||||
return request("votes", { mandate: String(mandateID), range_end: "200" }, voteSchema)
|
||||
}
|
||||
|
||||
export function fetchPollsByLegislature(legislatureId: number, rangeEnd = 150): Promise<Poll[]> {
|
||||
return request(
|
||||
"polls",
|
||||
{
|
||||
parliament_period: String(legislatureId),
|
||||
range_end: String(rangeEnd),
|
||||
sort_by: "field_poll_date",
|
||||
sort_direction: "desc",
|
||||
},
|
||||
pollSchema,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchMandatesByParliamentPeriod(periodID: number): Promise<MandateWithPolitician[]> {
|
||||
return request(
|
||||
"candidacies-mandates",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export const AW_API_BASE = "https://www.abgeordnetenwatch.de/api/v2"
|
||||
export const AW_API_TIMEOUT_MS = 20_000
|
||||
|
||||
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "https://serve.uber.space/agw-api"
|
||||
export const DIP_API_BASE = "https://search.dip.bundestag.de/api/v1"
|
||||
export const DIP_API_KEY = "GmEPb1B.bfqJLIhcGAsH9fTJevTglhFpCoZyAAAdhp"
|
||||
export const BUNDESTAG_LEGISLATURE_ID = 161
|
||||
export const BUNDESTAG_WAHLPERIODE = 21
|
||||
|
||||
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "https://serve.uber.space/agw/api"
|
||||
export const VAPID_PUBLIC_KEY =
|
||||
import.meta.env.VITE_VAPID_PUBLIC_KEY ??
|
||||
"BBRownmmnmTqNCeiaKp_CzvfXXNhB6NG5LTXDQwnSDspGgdKaZv_0UoAo4Ify6HHqcl4kY3ABw7w6GYAqUHYcdQ"
|
||||
|
||||
64
src/shared/lib/dip-api.ts
Normal file
64
src/shared/lib/dip-api.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod"
|
||||
import { BUNDESTAG_WAHLPERIODE, DIP_API_BASE, DIP_API_KEY } from "./constants"
|
||||
|
||||
const AW_API_TIMEOUT_MS = 20_000
|
||||
|
||||
// --- Zod Schemas ---
|
||||
|
||||
export const vorgangSchema = z.object({
|
||||
id: z.number(),
|
||||
titel: z.string(),
|
||||
beratungsstand: z.string().nullable().optional(),
|
||||
datum: z.string().nullable().optional(),
|
||||
vorgangstyp: z.string().nullable().optional(),
|
||||
sachgebiet: z.array(z.string()).nullable().optional(),
|
||||
})
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type Vorgang = z.infer<typeof vorgangSchema>
|
||||
|
||||
// --- Fetch helper ---
|
||||
|
||||
async function dipRequest<T>(path: string, params: Record<string, string>, schema: z.ZodType<T>): Promise<T[]> {
|
||||
const url = new URL(`${DIP_API_BASE}/${path}`)
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), AW_API_TIMEOUT_MS)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url.toString(), {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `ApiKey ${DIP_API_KEY}`,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "")
|
||||
throw new Error(`DIP API ${res.status} for ${url}: ${body}`)
|
||||
}
|
||||
|
||||
const json = (await res.json()) as { documents: unknown[] }
|
||||
return z.array(schema).parse(json.documents)
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function fetchUpcomingVorgaenge(): Promise<Vorgang[]> {
|
||||
return dipRequest(
|
||||
"vorgang",
|
||||
{
|
||||
"f.beratungsstand": "Beschlussempfehlung liegt vor",
|
||||
"f.vorgangstyp": "Gesetzgebung",
|
||||
"f.wahlperiode": String(BUNDESTAG_WAHLPERIODE),
|
||||
format: "json",
|
||||
},
|
||||
vorgangSchema,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user