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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user