add legislation detail page with vote widget, hooks, route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useLegislation } from "../hooks/use-legislation"
|
||||||
|
import { useUserVote } from "../hooks/use-user-vote"
|
||||||
|
import { VoteWidget } from "./vote-widget"
|
||||||
|
|
||||||
|
interface LegislationDetailProps {
|
||||||
|
legislationId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LegislationDetail({ legislationId }: LegislationDetailProps) {
|
||||||
|
const { legislation, loading, error } = useLegislation(legislationId)
|
||||||
|
const { vote, castVote } = useUserVote(legislationId)
|
||||||
|
const [showFullText, setShowFullText] = useState(false)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-4 text-center text-muted-foreground">Laden…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !legislation) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-center text-destructive">
|
||||||
|
{error ?? "Gesetzesvorlage nicht gefunden"}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold leading-tight">
|
||||||
|
{legislation.title}
|
||||||
|
</h1>
|
||||||
|
{legislation.beratungsstand && (
|
||||||
|
<span className="inline-block mt-2 text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-800">
|
||||||
|
{legislation.beratungsstand}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary or abstract */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{legislation.summary ? (
|
||||||
|
<p className="text-sm leading-relaxed">{legislation.summary}</p>
|
||||||
|
) : legislation.abstract ? (
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{legislation.abstract}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
Keine Zusammenfassung verfügbar
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full text toggle */}
|
||||||
|
{legislation.fullText && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowFullText(!showFullText)}
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{showFullText ? "Volltext ausblenden" : "Volltext anzeigen"}
|
||||||
|
</button>
|
||||||
|
{showFullText && (
|
||||||
|
<pre className="mt-2 p-3 bg-muted rounded-lg text-xs overflow-auto max-h-96 whitespace-pre-wrap">
|
||||||
|
{legislation.fullText}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vote section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium">Dein Vote</h2>
|
||||||
|
<VoteWidget currentVote={vote} onVote={castVote} />
|
||||||
|
{vote && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Du hast mit{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{vote === "ja" ? "Ja" : vote === "nein" ? "Nein" : "Enthaltung"}
|
||||||
|
</span>{" "}
|
||||||
|
gestimmt. Du kannst deine Stimme jederzeit ändern.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/client/features/legislation/components/vote-widget.tsx
Normal file
42
src/client/features/legislation/components/vote-widget.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { UserVoteChoice } from "../../../../shared/legislation-types"
|
||||||
|
|
||||||
|
interface VoteWidgetProps {
|
||||||
|
currentVote: UserVoteChoice | null
|
||||||
|
onVote: (choice: UserVoteChoice) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const choices: { value: UserVoteChoice; label: string; color: string }[] = [
|
||||||
|
{ value: "ja", label: "Ja", color: "bg-green-600 hover:bg-green-700" },
|
||||||
|
{ value: "nein", label: "Nein", color: "bg-red-600 hover:bg-red-700" },
|
||||||
|
{
|
||||||
|
value: "enthaltung",
|
||||||
|
label: "Enthaltung",
|
||||||
|
color: "bg-gray-500 hover:bg-gray-600",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function VoteWidget({ currentVote, onVote, disabled }: VoteWidgetProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{choices.map((c) => {
|
||||||
|
const isSelected = currentVote === c.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onVote(c.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex-1 py-3 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
isSelected
|
||||||
|
? `${c.color} text-white ring-2 ring-offset-2 ring-current`
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/client/features/legislation/hooks/use-legislation.ts
Normal file
37
src/client/features/legislation/hooks/use-legislation.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import type { LegislationDetail } from "../../../../shared/legislation-types"
|
||||||
|
import { fetchLegislation } from "../lib/legislation-api"
|
||||||
|
|
||||||
|
interface UseLegislationReturn {
|
||||||
|
legislation: LegislationDetail | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLegislation(legislationId: number): UseLegislationReturn {
|
||||||
|
const [legislation, setLegislation] = useState<LegislationDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await fetchLegislation(legislationId)
|
||||||
|
if (!cancelled) setLegislation(data)
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(String(err))
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [legislationId])
|
||||||
|
|
||||||
|
return { legislation, loading, error }
|
||||||
|
}
|
||||||
66
src/client/features/legislation/hooks/use-user-vote.ts
Normal file
66
src/client/features/legislation/hooks/use-user-vote.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useDb } from "@/shared/db/provider"
|
||||||
|
import { useDeviceId } from "@/shared/hooks/use-device-id"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import type { UserVoteChoice } from "../../../../shared/legislation-types"
|
||||||
|
import { castVote as apiCastVote, fetchUserVote } from "../lib/legislation-api"
|
||||||
|
import { getUserVote, saveUserVote } from "../lib/user-votes-db"
|
||||||
|
|
||||||
|
interface UseUserVoteReturn {
|
||||||
|
vote: UserVoteChoice | null
|
||||||
|
loading: boolean
|
||||||
|
castVote: (choice: UserVoteChoice) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserVote(legislationId: number): UseUserVoteReturn {
|
||||||
|
const db = useDb()
|
||||||
|
const deviceId = useDeviceId()
|
||||||
|
const [vote, setVote] = useState<UserVoteChoice | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
// load from local DB first for instant UI
|
||||||
|
const local = await getUserVote(db, legislationId)
|
||||||
|
if (!cancelled && local) {
|
||||||
|
setVote(local.vote as UserVoteChoice)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync from backend if device ID is available
|
||||||
|
if (deviceId) {
|
||||||
|
const remote = await fetchUserVote(legislationId, deviceId).catch(
|
||||||
|
() => null,
|
||||||
|
)
|
||||||
|
if (!cancelled && remote) {
|
||||||
|
setVote(remote.vote)
|
||||||
|
await saveUserVote(db, legislationId, remote.vote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [db, legislationId, deviceId])
|
||||||
|
|
||||||
|
const cast = useCallback(
|
||||||
|
async (choice: UserVoteChoice) => {
|
||||||
|
// optimistic local update
|
||||||
|
setVote(choice)
|
||||||
|
await saveUserVote(db, legislationId, choice)
|
||||||
|
|
||||||
|
// sync to backend
|
||||||
|
if (deviceId) {
|
||||||
|
await apiCastVote(legislationId, deviceId, choice)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[db, legislationId, deviceId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { vote, loading, castVote: cast }
|
||||||
|
}
|
||||||
1
src/client/features/legislation/index.ts
Normal file
1
src/client/features/legislation/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LegislationDetail } from "./components/legislation-detail"
|
||||||
11
src/client/routes/app/legislation.$legislationId.tsx
Normal file
11
src/client/routes/app/legislation.$legislationId.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { LegislationDetail } from "@/features/legislation"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
function LegislationPage() {
|
||||||
|
const { legislationId } = Route.useParams()
|
||||||
|
return <LegislationDetail legislationId={Number(legislationId)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/app/legislation/$legislationId")({
|
||||||
|
component: LegislationPage,
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user