add unfollow-all button in settings, follow-nearby button, collapsible party groups with 3 states

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 22:40:52 +01:00
parent 5337214d18
commit 9f691c711b
4 changed files with 158 additions and 43 deletions

View File

@@ -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<GeoResult | null>(null)
@@ -184,6 +184,18 @@ export function SettingsPage() {
</div>
</section>
{/* --- Follows --- */}
{follows.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Follows</h2>
<div className="flex flex-col gap-2">
<Button size="sm" variant="outline" onClick={unfollowAll}>
Allen Themen und Abgeordneten entfolgen ({follows.length})
</Button>
</div>
</section>
)}
{/* --- App Update --- */}
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">App-Update</h2>

View File

@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"
/>
<circle cx="12" cy="9" r="2.5" />
</svg>
)
}
if (state === "collapsed") {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
)
}
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
)
}
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<Record<string, CollapseState>>({})
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 (
<div className="pb-4">
<div className="px-4 py-3">
<div className="px-4 py-3 space-y-2">
<Input
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Name filtern…"
type="search"
/>
{userCity && (
<Button size="sm" variant="outline" className="w-full" onClick={handleFollowNearby}>
Abgeordneten in Deiner Nähe folgen
</Button>
)}
</div>
<div className="px-4 space-y-3">
{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 (
<Card key={group.partyLabel} className="py-0 gap-0 overflow-hidden">
<div
className="flex items-center gap-2.5 px-4 py-2.5"
<button
type="button"
className="w-full flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted/50 transition-colors"
style={{ borderLeftWidth: 4, borderLeftColor: meta.color }}
onClick={() => toggleGroup(group.partyLabel, hasNearby)}
aria-expanded={state !== "collapsed"}
>
<span
className="inline-flex items-center justify-center w-7 h-7 rounded-full text-[10px] font-bold text-white shrink-0"
@@ -107,43 +197,46 @@ export function RepresentativeList({ mandates, userCity, searchQuery, onSearchCh
{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)
const local = userCity ? isLocalConstituency(m, userCity) : false
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}
{local && <span className="ml-1.5 text-primary font-medium"> Dein Wahlkreis</span>}
</p>
)}
<span className="text-xs text-muted-foreground ml-auto mr-2">{group.members.length}</span>
<CollapseIcon state={state} />
</button>
{visibleMembers.length > 0 && (
<CardContent className="p-0">
<div className="divide-y divide-border">
{visibleMembers.map((m) => {
const followed = isFollowing("politician", m.politician.id)
const fn = mandateFunction(m)
const local = userCity ? isLocalConstituency(m, userCity) : false
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}
{local && <span className="ml-1.5 text-primary font-medium"> in Deiner Nähe</span>}
</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>
<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>
)
})}
</div>
</CardContent>
)}
</Card>
)
})}

View File

@@ -27,3 +27,8 @@ export async function addFollow(
export async function removeFollow(db: PGlite, type: "topic" | "politician", entityId: number): Promise<void> {
await db.query("DELETE FROM follows WHERE type = $1 AND entity_id = $2", [type, entityId])
}
export async function removeAllFollows(db: PGlite): Promise<number> {
const res = await db.query("DELETE FROM follows")
return res.affectedRows ?? 0
}

View File

@@ -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 }
}