diff --git a/src/features/settings/components/settings-page.tsx b/src/features/settings/components/settings-page.tsx index f23b50a..bee6cc9 100644 --- a/src/features/settings/components/settings-page.tsx +++ b/src/features/settings/components/settings-page.tsx @@ -25,7 +25,7 @@ export function SettingsPage() { const deviceId = useDeviceId() const { needRefresh, checkForUpdate, applyUpdate } = usePwaUpdate() const push = usePush() - const { follow } = useFollows() + const { follows, follow, unfollowAll } = useFollows() const [checking, setChecking] = useState(false) const [loading, setLoading] = useState(false) const [result, setResult] = useState(null) @@ -184,6 +184,18 @@ export function SettingsPage() { + {/* --- Follows --- */} + {follows.length > 0 && ( +
+

Follows

+
+ +
+
+ )} + {/* --- App Update --- */}

App-Update

diff --git a/src/shared/components/representative-list.tsx b/src/shared/components/representative-list.tsx index aa5ef49..47a9b1c 100644 --- a/src/shared/components/representative-list.tsx +++ b/src/shared/components/representative-list.tsx @@ -1,6 +1,7 @@ import { getPartyMeta } from "@/features/location/lib/parties" import { useFollows } from "@/shared/hooks/use-follows" import type { MandateWithPolitician } from "@/shared/lib/aw-api" +import { useState } from "react" import { Button } from "./ui/button" import { Card, CardContent } from "./ui/card" import { Input } from "./ui/input" @@ -19,7 +20,7 @@ function partyLabel(m: MandateWithPolitician): string { } /** Check if a mandate's constituency label contains the user's city name. */ -function isLocalConstituency(m: MandateWithPolitician, city: string): boolean { +export function isLocalConstituency(m: MandateWithPolitician, city: string): boolean { const label = m.electoral_data?.constituency?.label if (!label) return false return label.toLowerCase().includes(city.toLowerCase()) @@ -64,6 +65,65 @@ export function mandateFunction(m: MandateWithPolitician): string | null { return won } +type CollapseState = "expanded" | "nearby" | "collapsed" + +function nextState(current: CollapseState, hasNearby: boolean): CollapseState { + if (current === "expanded") return hasNearby ? "nearby" : "collapsed" + if (current === "nearby") return "collapsed" + return "expanded" +} + +function CollapseIcon({ state }: { state: CollapseState }) { + if (state === "nearby") { + return ( + + ) + } + if (state === "collapsed") { + return ( + + ) + } + return ( + + ) +} + interface RepresentativeListProps { mandates: MandateWithPolitician[] userCity?: string | null @@ -73,31 +133,61 @@ interface RepresentativeListProps { export function RepresentativeList({ mandates, userCity, searchQuery, onSearchChange }: RepresentativeListProps) { const { isFollowing, follow, unfollow } = useFollows() + const [collapseStates, setCollapseStates] = useState>({}) const filtered = searchQuery ? mandates.filter((m) => m.politician.label.toLowerCase().includes(searchQuery.toLowerCase())) : mandates const groups = groupByParty(filtered, userCity) + function toggleGroup(partyLabel: string, hasNearby: boolean) { + setCollapseStates((prev) => ({ + ...prev, + [partyLabel]: nextState(prev[partyLabel] ?? "expanded", hasNearby), + })) + } + + function handleFollowNearby() { + if (!userCity) return + for (const m of mandates) { + if (isLocalConstituency(m, userCity)) { + follow("politician", m.politician.id, m.politician.label) + } + } + } + return (
-
+
onSearchChange(e.target.value)} placeholder="Name filtern…" type="search" /> + {userCity && ( + + )}
{groups.map((group) => { const meta = getPartyMeta(group.partyLabel) + const state = collapseStates[group.partyLabel] ?? "expanded" + const nearbyMembers = userCity ? group.members.filter((m) => isLocalConstituency(m, userCity)) : [] + const hasNearby = nearbyMembers.length > 0 + const visibleMembers = state === "expanded" ? group.members : state === "nearby" ? nearbyMembers : [] + return ( -
toggleGroup(group.partyLabel, hasNearby)} + aria-expanded={state !== "collapsed"} > {group.partyLabel} - {group.members.length} -
- -
- {group.members.map((m) => { - const followed = isFollowing("politician", m.politician.id) - const fn = mandateFunction(m) - const local = userCity ? isLocalConstituency(m, userCity) : false - return ( -
-
-

{m.politician.label}

- {fn && ( -

- {fn} - {local && — Dein Wahlkreis} -

- )} + {group.members.length} + + + {visibleMembers.length > 0 && ( + +
+ {visibleMembers.map((m) => { + const followed = isFollowing("politician", m.politician.id) + const fn = mandateFunction(m) + const local = userCity ? isLocalConstituency(m, userCity) : false + return ( +
+
+

{m.politician.label}

+ {fn && ( +

+ {fn} + {local && — in Deiner Nähe} +

+ )} +
+
- -
- ) - })} -
- + ) + })} +
+ + )} ) })} diff --git a/src/shared/db/follows.ts b/src/shared/db/follows.ts index 25368ba..33e171d 100644 --- a/src/shared/db/follows.ts +++ b/src/shared/db/follows.ts @@ -27,3 +27,8 @@ export async function addFollow( export async function removeFollow(db: PGlite, type: "topic" | "politician", entityId: number): Promise { await db.query("DELETE FROM follows WHERE type = $1 AND entity_id = $2", [type, entityId]) } + +export async function removeAllFollows(db: PGlite): Promise { + const res = await db.query("DELETE FROM follows") + return res.affectedRows ?? 0 +} diff --git a/src/shared/hooks/use-follows.ts b/src/shared/hooks/use-follows.ts index 248d5c2..14bcc09 100644 --- a/src/shared/hooks/use-follows.ts +++ b/src/shared/hooks/use-follows.ts @@ -1,4 +1,4 @@ -import { type Follow, addFollow, getFollows, removeFollow } from "@/shared/db/follows" +import { type Follow, addFollow, getFollows, removeAllFollows, removeFollow } from "@/shared/db/follows" import { useDb } from "@/shared/db/provider" import { useCallback, useEffect, useState } from "react" @@ -49,5 +49,10 @@ export function useFollows() { [db], ) - return { follows, isFollowing, follow, unfollow } + const unfollowAll = useCallback(async () => { + await removeAllFollows(db) + emitChange() + }, [db]) + + return { follows, isFollowing, follow, unfollow, unfollowAll } }