Initial commit

This commit is contained in:
Jack Levy
2026-02-28 21:08:19 -05:00
commit e418dd9ae0
85 changed files with 5261 additions and 0 deletions

31
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json ./
RUN npm install
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN mkdir -p public && npm run build
FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -0,0 +1,93 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { ArrowLeft, ExternalLink, User } from "lucide-react";
import { useBill, useBillTrend } from "@/lib/hooks/useBills";
import { AIBriefCard } from "@/components/bills/AIBriefCard";
import { ActionTimeline } from "@/components/bills/ActionTimeline";
import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton";
import { billLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const billId = decodeURIComponent(id);
const { data: bill, isLoading } = useBill(billId);
const { data: trendData } = useBillTrend(billId, 30);
if (isLoading) {
return <div className="text-center py-20 text-muted-foreground">Loading bill...</div>;
}
if (!bill) {
return (
<div className="text-center py-20">
<p className="text-muted-foreground">Bill not found.</p>
<Link href="/bills" className="text-sm text-primary mt-2 inline-block"> Back to bills</Link>
</div>
);
}
const label = billLabel(bill.bill_type, bill.bill_number);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<Link href="/bills" className="text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="w-4 h-4" />
</Link>
<span className="font-mono text-sm font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded">
{label}
</span>
<span className="text-sm text-muted-foreground">{bill.chamber}</span>
<span className="text-sm text-muted-foreground">119th Congress</span>
</div>
<h1 className="text-xl font-bold leading-snug">
{bill.short_title || bill.title || "Untitled Bill"}
</h1>
{bill.sponsor && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<User className="w-3.5 h-3.5" />
<Link href={`/members/${bill.sponsor.bioguide_id}`} className="hover:text-foreground transition-colors">
{bill.sponsor.name}
</Link>
{bill.sponsor.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(bill.sponsor.party))}>
{bill.sponsor.party}
</span>
)}
{bill.sponsor.state && <span>{bill.sponsor.state}</span>}
</div>
)}
<p className="text-xs text-muted-foreground mt-1">
Introduced: {formatDate(bill.introduced_date)}
{bill.congress_url && (
<a href={bill.congress_url} target="_blank" rel="noopener noreferrer" className="ml-3 hover:text-primary transition-colors">
congress.gov <ExternalLink className="w-3 h-3 inline" />
</a>
)}
</p>
</div>
<FollowButton type="bill" value={bill.bill_id} />
</div>
{/* Content grid */}
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2 space-y-6">
<AIBriefCard brief={bill.latest_brief} />
<ActionTimeline actions={bill.actions} />
</div>
<div className="space-y-4">
<TrendChart data={trendData} />
<NewsPanel articles={bill.news_articles} />
</div>
</div>
</div>
);
}

109
frontend/app/bills/page.tsx Normal file
View File

@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { Search, Filter } from "lucide-react";
import { useBills } from "@/lib/hooks/useBills";
import { BillCard } from "@/components/shared/BillCard";
const CHAMBERS = ["", "House", "Senate"];
const TOPICS = [
"", "healthcare", "taxation", "defense", "education", "immigration",
"environment", "housing", "infrastructure", "technology", "agriculture",
"judiciary", "foreign-policy", "veterans", "social-security", "trade",
"budget", "energy", "banking", "transportation", "labor",
];
export default function BillsPage() {
const [q, setQ] = useState("");
const [chamber, setChamber] = useState("");
const [topic, setTopic] = useState("");
const [page, setPage] = useState(1);
const params = {
...(q && { q }),
...(chamber && { chamber }),
...(topic && { topic }),
page,
per_page: 20,
sort: "latest_action_date",
};
const { data, isLoading } = useBills(params);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Bills</h1>
<p className="text-muted-foreground text-sm mt-1">Browse and search US Congressional legislation</p>
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search bills..."
value={q}
onChange={(e) => { setQ(e.target.value.trim()); setPage(1); }}
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<select
value={chamber}
onChange={(e) => { setChamber(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
>
<option value="">All Chambers</option>
{CHAMBERS.slice(1).map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<select
value={topic}
onChange={(e) => { setTopic(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
>
<option value="">All Topics</option>
{TOPICS.slice(1).map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
{/* Results */}
{isLoading ? (
<div className="text-center py-20 text-muted-foreground">Loading bills...</div>
) : (
<>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{data?.total ?? 0} bills found</span>
<span>Page {data?.page} of {data?.pages}</span>
</div>
<div className="space-y-3">
{data?.items?.map((bill) => (
<BillCard key={bill.bill_id} bill={bill} />
))}
</div>
{/* Pagination */}
{data && data.pages > 1 && (
<div className="flex justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 text-sm bg-card border border-border rounded-md disabled:opacity-40 hover:bg-accent transition-colors"
>
Previous
</button>
<button
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
className="px-4 py-2 text-sm bg-card border border-border rounded-md disabled:opacity-40 hover:bg-accent transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { Heart, X } from "lucide-react";
import { useFollows, useRemoveFollow } from "@/lib/hooks/useFollows";
import { billLabel } from "@/lib/utils";
export default function FollowingPage() {
const { data: follows = [], isLoading } = useFollows();
const remove = useRemoveFollow();
const bills = follows.filter((f) => f.follow_type === "bill");
const members = follows.filter((f) => f.follow_type === "member");
const topics = follows.filter((f) => f.follow_type === "topic");
const Section = ({ title, items, renderValue }: {
title: string;
items: typeof follows;
renderValue: (v: string) => React.ReactNode;
}) => (
<div>
<h2 className="font-semibold mb-3">{title} ({items.length})</h2>
{!items.length ? (
<p className="text-sm text-muted-foreground">Nothing followed yet.</p>
) : (
<div className="space-y-2">
{items.map((f) => (
<div key={f.id} className="bg-card border border-border rounded-lg p-3 flex items-center justify-between">
<div>{renderValue(f.follow_value)}</div>
<button
onClick={() => remove.mutate(f.id)}
className="text-muted-foreground hover:text-destructive transition-colors p-1"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
);
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Heart className="w-5 h-5" /> Following
</h1>
<p className="text-muted-foreground text-sm mt-1">Manage what you follow</p>
</div>
<Section
title="Bills"
items={bills}
renderValue={(v) => {
const [congress, type, num] = v.split("-");
return (
<Link href={`/bills/${v}`} className="text-sm font-medium hover:text-primary transition-colors">
{type && num ? billLabel(type, parseInt(num)) : v}
</Link>
);
}}
/>
<Section
title="Members"
items={members}
renderValue={(v) => (
<Link href={`/members/${v}`} className="text-sm font-medium hover:text-primary transition-colors">
{v}
</Link>
)}
/>
<Section
title="Topics"
items={topics}
renderValue={(v) => (
<Link href={`/bills?topic=${v}`} className="text-sm font-medium hover:text-primary transition-colors capitalize">
{v.replace("-", " ")}
</Link>
)}
/>
</div>
);
}

55
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,55 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 0.5rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 6%;
--card-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

35
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import { Sidebar } from "@/components/shared/Sidebar";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "PocketVeto",
description: "Monitor US Congress with AI-powered bill summaries and trend analysis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="container mx-auto px-6 py-6 max-w-7xl">
{children}
</div>
</main>
</div>
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { useMember, useMemberBills } from "@/lib/hooks/useMembers";
import { FollowButton } from "@/components/shared/FollowButton";
import { BillCard } from "@/components/shared/BillCard";
import { cn, partyBadgeColor } from "@/lib/utils";
export default function MemberDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { data: member, isLoading } = useMember(id);
const { data: billsData } = useMemberBills(id);
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
if (!member) return <div className="text-center py-20 text-muted-foreground">Member not found.</div>;
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<Link href="/members" className="mt-1 text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h1 className="text-2xl font-bold">{member.name}</h1>
<div className="flex items-center gap-2 mt-1">
{member.party && (
<span className={cn("px-2 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.state && <span className="text-sm text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-sm text-muted-foreground">{member.chamber}</span>}
{member.district && <span className="text-sm text-muted-foreground">District {member.district}</span>}
</div>
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
</div>
<div>
<h2 className="font-semibold mb-4">Sponsored Bills ({billsData?.total ?? 0})</h2>
{!billsData?.items?.length ? (
<p className="text-sm text-muted-foreground">No bills found.</p>
) : (
<div className="space-y-3">
{billsData.items.map((bill) => <BillCard key={bill.bill_id} bill={bill} />)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Search } from "lucide-react";
import { useMembers } from "@/lib/hooks/useMembers";
import { FollowButton } from "@/components/shared/FollowButton";
import { cn, partyBadgeColor } from "@/lib/utils";
export default function MembersPage() {
const [q, setQ] = useState("");
const [chamber, setChamber] = useState("");
const [party, setParty] = useState("");
const [page, setPage] = useState(1);
const { data, isLoading } = useMembers({
...(q && { q }), ...(chamber && { chamber }), ...(party && { party }),
page, per_page: 50,
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Members</h1>
<p className="text-muted-foreground text-sm mt-1">Browse current Congress members</p>
</div>
<div className="flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name..."
value={q}
onChange={(e) => { setQ(e.target.value.trim()); setPage(1); }}
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
/>
</div>
<select value={chamber} onChange={(e) => { setChamber(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-card border border-border rounded-md">
<option value="">All Chambers</option>
<option value="House of Representatives">House</option>
<option value="Senate">Senate</option>
</select>
<select value={party} onChange={(e) => { setParty(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-card border border-border rounded-md">
<option value="">All Parties</option>
<option value="Democratic">Democratic</option>
<option value="Republican">Republican</option>
<option value="Independent">Independent</option>
</select>
</div>
{isLoading ? (
<div className="text-center py-20 text-muted-foreground">Loading members...</div>
) : (
<>
<div className="text-sm text-muted-foreground">{data?.total ?? 0} members</div>
<div className="grid grid-cols-2 gap-3">
{data?.items?.map((member) => (
<div key={member.bioguide_id} className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors">
{member.name}
</Link>
<div className="flex items-center gap-1.5 mt-1">
{member.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
</div>
))}
</div>
{data && data.pages > 1 && (
<div className="flex justify-center gap-2">
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}
className="px-4 py-2 text-sm bg-card border border-border rounded-md disabled:opacity-40 hover:bg-accent">Previous</button>
<button onClick={() => setPage((p) => Math.min(data.pages, p + 1))} disabled={page === data.pages}
className="px-4 py-2 text-sm bg-card border border-border rounded-md disabled:opacity-40 hover:bg-accent">Next</button>
</div>
)}
</>
)}
</div>
);
}

89
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,89 @@
"use client";
import { TrendingUp, BookOpen, RefreshCw } from "lucide-react";
import { useDashboard } from "@/lib/hooks/useDashboard";
import { BillCard } from "@/components/shared/BillCard";
import { adminAPI } from "@/lib/api";
import { useState } from "react";
export default function DashboardPage() {
const { data, isLoading, refetch } = useDashboard();
const [polling, setPolling] = useState(false);
const triggerPoll = async () => {
setPolling(true);
try {
await adminAPI.triggerPoll();
setTimeout(() => { refetch(); setPolling(false); }, 3000);
} catch { setPolling(false); }
};
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground text-sm mt-1">
Your personalized Congressional activity feed
</p>
</div>
<button
onClick={triggerPoll}
disabled={polling}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${polling ? "animate-spin" : ""}`} />
{polling ? "Polling..." : "Poll Now"}
</button>
</div>
{isLoading ? (
<div className="text-center py-20 text-muted-foreground">Loading dashboard...</div>
) : (
<div className="grid grid-cols-3 gap-8">
<div className="col-span-2 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Your Feed
{data?.follows && (
<span className="text-xs text-muted-foreground font-normal">
({data.follows.bills} bills · {data.follows.members} members · {data.follows.topics} topics)
</span>
)}
</h2>
{!data?.feed?.length ? (
<div className="bg-card border border-border rounded-lg p-8 text-center text-muted-foreground">
<p className="text-sm">Your feed is empty.</p>
<p className="text-xs mt-1">Follow bills, members, or topics to see activity here.</p>
</div>
) : (
<div className="space-y-3">
{data.feed.map((bill) => (
<BillCard key={bill.bill_id} bill={bill} />
))}
</div>
)}
</div>
<div className="space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Trending
</h2>
{!data?.trending?.length ? (
<div className="bg-card border border-border rounded-lg p-6 text-center text-muted-foreground text-xs">
No trend data yet. Run a poll to populate.
</div>
) : (
<div className="space-y-2">
{data.trending.map((bill) => (
<BillCard key={bill.bill_id} bill={bill} compact />
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{children}
</ThemeProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Settings, Cpu, RefreshCw, CheckCircle, XCircle, Play } from "lucide-react";
import { settingsAPI, adminAPI } from "@/lib/api";
const LLM_PROVIDERS = [
{ value: "openai", label: "OpenAI (GPT-4o)", hint: "Requires OPENAI_API_KEY in .env" },
{ value: "anthropic", label: "Anthropic (Claude)", hint: "Requires ANTHROPIC_API_KEY in .env" },
{ value: "gemini", label: "Google Gemini", hint: "Requires GEMINI_API_KEY in .env" },
{ value: "ollama", label: "Ollama (Local)", hint: "Requires Ollama running on host" },
];
export default function SettingsPage() {
const qc = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get(),
});
const updateSetting = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) => settingsAPI.update(key, value),
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings"] }),
});
const [testResult, setTestResult] = useState<{ status: string; detail?: string; summary_preview?: string; provider?: string } | null>(null);
const [testing, setTesting] = useState(false);
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
const testLLM = async () => {
setTesting(true);
setTestResult(null);
try {
const result = await settingsAPI.testLLM();
setTestResult(result);
} catch (e: unknown) {
setTestResult({ status: "error", detail: e instanceof Error ? e.message : String(e) });
} finally {
setTesting(false);
}
};
const trigger = async (name: string, fn: () => Promise<{ task_id: string }>) => {
const result = await fn();
setTaskIds((prev) => ({ ...prev, [name]: result.task_id }));
};
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading settings...</div>;
return (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Settings className="w-5 h-5" /> Settings
</h1>
<p className="text-muted-foreground text-sm mt-1">Configure LLM provider and system settings</p>
</div>
{/* LLM Provider */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<Cpu className="w-4 h-4" /> LLM Provider
</h2>
<p className="text-xs text-muted-foreground">
Current: <strong>{settings?.llm_provider}</strong> / <strong>{settings?.llm_model}</strong>
</p>
<div className="space-y-2">
{LLM_PROVIDERS.map(({ value, label, hint }) => (
<label key={value} className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="provider"
value={value}
checked={settings?.llm_provider === value}
onChange={() => updateSetting.mutate({ key: "llm_provider", value })}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
</label>
))}
</div>
<div className="flex items-center gap-3 pt-2 border-t border-border">
<button
onClick={testLLM}
disabled={testing}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Play className="w-3.5 h-3.5" />
{testing ? "Testing..." : "Test Connection"}
</button>
{testResult && (
<div className="flex items-center gap-2 text-sm">
{testResult.status === "ok" ? (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600 dark:text-green-400">
{testResult.provider}/{testResult.summary_preview?.slice(0, 50)}...
</span>
</>
) : (
<>
<XCircle className="w-4 h-4 text-red-500" />
<span className="text-red-600 dark:text-red-400">{testResult.detail}</span>
</>
)}
</div>
)}
</div>
</section>
{/* Polling Settings */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<RefreshCw className="w-4 h-4" /> Data Sources
</h2>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Congress.gov Poll Interval</div>
<div className="text-xs text-muted-foreground">How often to check for new bills</div>
</div>
<select
value={settings?.congress_poll_interval_minutes}
onChange={(e) => updateSetting.mutate({ key: "congress_poll_interval_minutes", value: e.target.value })}
className="px-3 py-1.5 text-sm bg-background border border-border rounded-md"
>
<option value="15">Every 15 min</option>
<option value="30">Every 30 min</option>
<option value="60">Every hour</option>
<option value="360">Every 6 hours</option>
</select>
</div>
<div className="flex items-center justify-between py-2 border-t border-border">
<div>
<div className="font-medium">NewsAPI.org</div>
<div className="text-xs text-muted-foreground">100 requests/day free tier</div>
</div>
<span className={`text-xs font-medium ${settings?.newsapi_enabled ? "text-green-500" : "text-muted-foreground"}`}>
{settings?.newsapi_enabled ? "Configured" : "Not configured"}
</span>
</div>
<div className="flex items-center justify-between py-2 border-t border-border">
<div>
<div className="font-medium">Google Trends</div>
<div className="text-xs text-muted-foreground">Zeitgeist scoring via pytrends</div>
</div>
<span className={`text-xs font-medium ${settings?.pytrends_enabled ? "text-green-500" : "text-muted-foreground"}`}>
{settings?.pytrends_enabled ? "Enabled" : "Disabled"}
</span>
</div>
</div>
</section>
{/* Manual Controls */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold">Manual Controls</h2>
<div className="flex flex-wrap gap-3">
<button
onClick={() => trigger("poll", adminAPI.triggerPoll)}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Trigger Poll
</button>
<button
onClick={() => trigger("members", adminAPI.triggerMemberSync)}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Sync Members
</button>
<button
onClick={() => trigger("trends", adminAPI.triggerTrendScores)}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Calculate Trends
</button>
</div>
{Object.entries(taskIds).map(([name, id]) => (
<p key={name} className="text-xs text-muted-foreground">{name}: task {id} queued</p>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { Tags } from "lucide-react";
import { FollowButton } from "@/components/shared/FollowButton";
const TOPICS = [
{ tag: "healthcare", label: "Healthcare", desc: "Health policy, insurance, Medicare, Medicaid" },
{ tag: "taxation", label: "Taxation", desc: "Tax law, IRS, fiscal policy" },
{ tag: "defense", label: "Defense", desc: "Military, NDAA, national security" },
{ tag: "education", label: "Education", desc: "Schools, student loans, higher education" },
{ tag: "immigration", label: "Immigration", desc: "Border, visas, asylum, citizenship" },
{ tag: "environment", label: "Environment", desc: "Climate, EPA, conservation, energy" },
{ tag: "housing", label: "Housing", desc: "Affordable housing, mortgages, HUD" },
{ tag: "infrastructure", label: "Infrastructure", desc: "Roads, bridges, broadband, transit" },
{ tag: "technology", label: "Technology", desc: "AI, cybersecurity, telecom, internet" },
{ tag: "agriculture", label: "Agriculture", desc: "Farm bill, USDA, food policy" },
{ tag: "judiciary", label: "Judiciary", desc: "Courts, criminal justice, civil rights" },
{ tag: "foreign-policy", label: "Foreign Policy", desc: "Diplomacy, foreign aid, sanctions" },
{ tag: "veterans", label: "Veterans", desc: "VA, veteran benefits, military families" },
{ tag: "social-security", label: "Social Security", desc: "SS, Medicare, retirement benefits" },
{ tag: "trade", label: "Trade", desc: "Tariffs, trade agreements, WTO" },
{ tag: "budget", label: "Budget", desc: "Appropriations, debt ceiling, spending" },
{ tag: "energy", label: "Energy", desc: "Oil, gas, renewables, nuclear" },
{ tag: "banking", label: "Banking", desc: "Financial regulation, Fed, CFPB" },
{ tag: "transportation", label: "Transportation", desc: "FAA, DOT, aviation, rail" },
{ tag: "labor", label: "Labor", desc: "Minimum wage, unions, OSHA, employment" },
];
export default function TopicsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Topics</h1>
<p className="text-muted-foreground text-sm mt-1">
Follow topics to see related bills in your feed
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{TOPICS.map(({ tag, label, desc }) => (
<div key={tag} className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<Tags className="w-3.5 h-3.5 text-muted-foreground" />
<Link
href={`/bills?topic=${tag}`}
className="font-medium text-sm hover:text-primary transition-colors"
>
{label}
</Link>
</div>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<FollowButton type="topic" value={tag} />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { AlertTriangle, CheckCircle, Clock, Cpu, Tag } from "lucide-react";
import { BriefSchema } from "@/lib/types";
import { formatDate } from "@/lib/utils";
interface AIBriefCardProps {
brief?: BriefSchema | null;
}
export function AIBriefCard({ brief }: AIBriefCardProps) {
if (!brief) {
return (
<div className="bg-card border border-border rounded-lg p-6">
<div className="flex items-center gap-2 mb-3">
<Cpu className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold">AI Analysis</h2>
</div>
<p className="text-sm text-muted-foreground italic">
Analysis not yet generated. It will appear once the bill text has been processed.
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg p-6 space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-primary" />
<h2 className="font-semibold">AI Analysis</h2>
</div>
<span className="text-xs text-muted-foreground">
{brief.llm_provider}/{brief.llm_model} · {formatDate(brief.created_at)}
</span>
</div>
{brief.summary && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Summary</h3>
<p className="text-sm leading-relaxed whitespace-pre-line">{brief.summary}</p>
</div>
)}
{brief.key_points && brief.key_points.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Key Points</h3>
<ul className="space-y-1.5">
{brief.key_points.map((point, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<CheckCircle className="w-3.5 h-3.5 mt-0.5 text-green-500 shrink-0" />
<span>{point}</span>
</li>
))}
</ul>
</div>
)}
{brief.risks && brief.risks.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Risks & Concerns</h3>
<ul className="space-y-1.5">
{brief.risks.map((risk, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 text-yellow-500 shrink-0" />
<span>{risk}</span>
</li>
))}
</ul>
</div>
)}
{brief.deadlines && brief.deadlines.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Deadlines</h3>
<ul className="space-y-1.5">
{brief.deadlines.map((d, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Clock className="w-3.5 h-3.5 mt-0.5 text-blue-500 shrink-0" />
<span>
{d.date ? <strong>{formatDate(d.date)}: </strong> : ""}
{d.description}
</span>
</li>
))}
</ul>
</div>
)}
{brief.topic_tags && brief.topic_tags.length > 0 && (
<div className="flex items-center gap-2 pt-1 border-t border-border flex-wrap">
<Tag className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
{brief.topic_tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-1 bg-accent text-accent-foreground rounded-full"
>
{tag}
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { Clock } from "lucide-react";
import { BillAction } from "@/lib/types";
import { formatDate } from "@/lib/utils";
interface ActionTimelineProps {
actions: BillAction[];
}
export function ActionTimeline({ actions }: ActionTimelineProps) {
if (!actions || actions.length === 0) {
return (
<div className="bg-card border border-border rounded-lg p-6">
<h2 className="font-semibold mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
Action History
</h2>
<p className="text-sm text-muted-foreground italic">No actions recorded yet.</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg p-6">
<h2 className="font-semibold mb-4 flex items-center gap-2">
<Clock className="w-4 h-4" />
Action History
<span className="text-xs text-muted-foreground font-normal">({actions.length})</span>
</h2>
<div className="relative">
<div className="absolute left-2 top-0 bottom-0 w-px bg-border" />
<ul className="space-y-4 pl-7">
{actions.map((action, i) => (
<li key={action.id} className="relative">
<div className="absolute -left-5 top-1.5 w-2 h-2 rounded-full bg-primary/60 border-2 border-background" />
<div className="text-xs text-muted-foreground mb-0.5">
{formatDate(action.action_date)}
{action.chamber && ` · ${action.chamber}`}
</div>
<p className="text-sm leading-snug">{action.action_text}</p>
</li>
))}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { ExternalLink, Newspaper } from "lucide-react";
import { NewsArticle } from "@/lib/types";
import { formatDate } from "@/lib/utils";
interface NewsPanelProps {
articles?: NewsArticle[];
}
export function NewsPanel({ articles }: NewsPanelProps) {
return (
<div className="bg-card border border-border rounded-lg p-4">
<h3 className="font-semibold text-sm flex items-center gap-2 mb-3">
<Newspaper className="w-4 h-4" />
Related News
{articles && articles.length > 0 && (
<span className="text-xs text-muted-foreground font-normal">({articles.length})</span>
)}
</h3>
{!articles || articles.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No news articles found yet.</p>
) : (
<ul className="space-y-3">
{articles.slice(0, 8).map((article) => (
<li key={article.id} className="group">
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="block hover:text-primary transition-colors"
>
<p className="text-xs font-medium line-clamp-2 leading-snug group-hover:underline">
{article.headline}
<ExternalLink className="w-3 h-3 inline ml-1 opacity-50" />
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{article.source} · {formatDate(article.published_at)}
</p>
</a>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { TrendingUp } from "lucide-react";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
import { TrendScore } from "@/lib/types";
import { formatDate } from "@/lib/utils";
interface TrendChartProps {
data?: TrendScore[];
}
export function TrendChart({ data }: TrendChartProps) {
const chartData = data?.map((d) => ({
date: new Date(d.score_date).toLocaleDateString("en-US", { month: "short", day: "numeric" }),
score: Math.round(d.composite_score),
news: d.newsapi_count,
gnews: d.gnews_count,
})) ?? [];
const latest = data?.[data.length - 1]?.composite_score;
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-sm flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Public Interest
</h3>
{latest !== undefined && (
<span className="text-2xl font-bold tabular-nums">{Math.round(latest)}</span>
)}
</div>
{chartData.length === 0 ? (
<p className="text-xs text-muted-foreground italic text-center py-8">
Trend data not yet available.
</p>
) : (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
tickLine={false}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "6px",
fontSize: "12px",
}}
/>
<Line
type="monotone"
dataKey="score"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={false}
name="Zeitgeist Score"
/>
</LineChart>
</ResponsiveContainer>
)}
<div className="mt-3 flex gap-4 text-xs text-muted-foreground">
<span>Score: 0100 composite</span>
<span>NewsAPI + Google News + Trends</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import Link from "next/link";
import { TrendingUp, Calendar, User } from "lucide-react";
import { Bill } from "@/lib/types";
import { billLabel, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils";
import { FollowButton } from "./FollowButton";
interface BillCardProps {
bill: Bill;
compact?: boolean;
}
export function BillCard({ bill, compact = false }: BillCardProps) {
const label = billLabel(bill.bill_type, bill.bill_number);
const score = bill.latest_trend?.composite_score;
const tags = bill.latest_brief?.topic_tags?.slice(0, 3) || [];
return (
<div className="bg-card border border-border rounded-lg p-4 hover:border-primary/30 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="text-xs font-mono font-semibold text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{label}
</span>
<span className="text-xs text-muted-foreground">
{bill.chamber}
</span>
{tags.map((tag) => (
<span
key={tag}
className="text-xs px-1.5 py-0.5 rounded-full bg-accent text-accent-foreground"
>
{tag}
</span>
))}
</div>
<Link href={`/bills/${bill.bill_id}`}>
<h3 className="text-sm font-medium leading-snug hover:text-primary transition-colors line-clamp-2">
{bill.short_title || bill.title || "Untitled Bill"}
</h3>
</Link>
{!compact && bill.sponsor && (
<div className="flex items-center gap-1.5 mt-1.5 text-xs text-muted-foreground">
<User className="w-3 h-3" />
<Link href={`/members/${bill.sponsor.bioguide_id}`} className="hover:text-foreground transition-colors">
{bill.sponsor.name}
</Link>
{bill.sponsor.party && (
<span className={cn("px-1 py-0.5 rounded text-xs font-medium", partyBadgeColor(bill.sponsor.party))}>
{bill.sponsor.party}
</span>
)}
{bill.sponsor.state && (
<span>{bill.sponsor.state}</span>
)}
</div>
)}
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<FollowButton type="bill" value={bill.bill_id} />
{score !== undefined && score > 0 && (
<div className={cn("flex items-center gap-1 text-xs font-medium", trendColor(score))}>
<TrendingUp className="w-3 h-3" />
{Math.round(score)}
</div>
)}
</div>
</div>
{!compact && bill.latest_action_text && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2 border-t border-border pt-2">
<Calendar className="w-3 h-3 inline mr-1" />
{formatDate(bill.latest_action_date)} {bill.latest_action_text}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { Heart } from "lucide-react";
import { useAddFollow, useIsFollowing, useRemoveFollow } from "@/lib/hooks/useFollows";
import { cn } from "@/lib/utils";
interface FollowButtonProps {
type: "bill" | "member" | "topic";
value: string;
label?: string;
}
export function FollowButton({ type, value, label }: FollowButtonProps) {
const existing = useIsFollowing(type, value);
const add = useAddFollow();
const remove = useRemoveFollow();
const isFollowing = !!existing;
const isPending = add.isPending || remove.isPending;
const handleClick = () => {
if (isFollowing && existing) {
remove.mutate(existing.id);
} else {
add.mutate({ type, value });
}
};
return (
<button
onClick={handleClick}
disabled={isPending}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
isFollowing
? "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
<Heart className={cn("w-3.5 h-3.5", isFollowing && "fill-current")} />
{isFollowing ? "Unfollow" : label || "Follow"}
</button>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
FileText,
Users,
Tags,
Heart,
Settings,
Landmark,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { ThemeToggle } from "./ThemeToggle";
const NAV = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/bills", label: "Bills", icon: FileText },
{ href: "/members", label: "Members", icon: Users },
{ href: "/topics", label: "Topics", icon: Tags },
{ href: "/following", label: "Following", icon: Heart },
{ href: "/settings", label: "Settings", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col">
<div className="p-4 border-b border-border flex items-center gap-2">
<Landmark className="w-5 h-5 text-primary" />
<span className="font-semibold text-sm">PocketVeto</span>
</div>
<nav className="flex-1 p-3 space-y-1">
{NAV.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="w-4 h-4 shrink-0" />
{label}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-border flex items-center justify-between">
<span className="text-xs text-muted-foreground">Theme</span>
<ThemeToggle />
</div>
</aside>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
aria-label="Toggle theme"
>
<Sun className="w-4 h-4 hidden dark:block" />
<Moon className="w-4 h-4 dark:hidden" />
</button>
);
}

86
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,86 @@
import axios from "axios";
import type {
Bill,
BillAction,
BillDetail,
DashboardData,
Follow,
Member,
NewsArticle,
PaginatedResponse,
SettingsData,
TrendScore,
} from "./types";
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || "",
timeout: 30000,
});
// Bills
export const billsAPI = {
list: (params?: Record<string, unknown>) =>
apiClient.get<PaginatedResponse<Bill>>("/api/bills", { params }).then((r) => r.data),
get: (id: string) =>
apiClient.get<BillDetail>(`/api/bills/${id}`).then((r) => r.data),
getActions: (id: string) =>
apiClient.get<BillAction[]>(`/api/bills/${id}/actions`).then((r) => r.data),
getNews: (id: string) =>
apiClient.get<NewsArticle[]>(`/api/bills/${id}/news`).then((r) => r.data),
getTrend: (id: string, days?: number) =>
apiClient.get<TrendScore[]>(`/api/bills/${id}/trend`, { params: { days } }).then((r) => r.data),
};
// Members
export const membersAPI = {
list: (params?: Record<string, unknown>) =>
apiClient.get<PaginatedResponse<Member>>("/api/members", { params }).then((r) => r.data),
get: (id: string) =>
apiClient.get<Member>(`/api/members/${id}`).then((r) => r.data),
getBills: (id: string, params?: Record<string, unknown>) =>
apiClient.get<PaginatedResponse<Bill>>(`/api/members/${id}/bills`, { params }).then((r) => r.data),
};
// Follows
export const followsAPI = {
list: () =>
apiClient.get<Follow[]>("/api/follows").then((r) => r.data),
add: (follow_type: string, follow_value: string) =>
apiClient.post<Follow>("/api/follows", { follow_type, follow_value }).then((r) => r.data),
remove: (id: number) =>
apiClient.delete(`/api/follows/${id}`),
};
// Dashboard
export const dashboardAPI = {
get: () =>
apiClient.get<DashboardData>("/api/dashboard").then((r) => r.data),
};
// Search
export const searchAPI = {
search: (q: string) =>
apiClient.get<{ bills: Bill[]; members: Member[] }>("/api/search", { params: { q } }).then((r) => r.data),
};
// Settings
export const settingsAPI = {
get: () =>
apiClient.get<SettingsData>("/api/settings").then((r) => r.data),
update: (key: string, value: string) =>
apiClient.put("/api/settings", { key, value }).then((r) => r.data),
testLLM: () =>
apiClient.post("/api/settings/test-llm").then((r) => r.data),
};
// Admin
export const adminAPI = {
triggerPoll: () =>
apiClient.post("/api/admin/trigger-poll").then((r) => r.data),
triggerMemberSync: () =>
apiClient.post("/api/admin/trigger-member-sync").then((r) => r.data),
triggerTrendScores: () =>
apiClient.post("/api/admin/trigger-trend-scores").then((r) => r.data),
getTaskStatus: (taskId: string) =>
apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data),
};

View File

@@ -0,0 +1,46 @@
import { useQuery } from "@tanstack/react-query";
import { billsAPI } from "../api";
export function useBills(params?: Record<string, unknown>) {
return useQuery({
queryKey: ["bills", params],
queryFn: () => billsAPI.list(params),
staleTime: 5 * 60 * 1000,
});
}
export function useBill(id: string) {
return useQuery({
queryKey: ["bill", id],
queryFn: () => billsAPI.get(id),
staleTime: 2 * 60 * 1000,
enabled: !!id,
});
}
export function useBillActions(id: string) {
return useQuery({
queryKey: ["bill-actions", id],
queryFn: () => billsAPI.getActions(id),
staleTime: 5 * 60 * 1000,
enabled: !!id,
});
}
export function useBillNews(id: string) {
return useQuery({
queryKey: ["bill-news", id],
queryFn: () => billsAPI.getNews(id),
staleTime: 10 * 60 * 1000,
enabled: !!id,
});
}
export function useBillTrend(id: string, days = 30) {
return useQuery({
queryKey: ["bill-trend", id, days],
queryFn: () => billsAPI.getTrend(id, days),
staleTime: 60 * 60 * 1000,
enabled: !!id,
});
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { dashboardAPI } from "../api";
export function useDashboard() {
return useQuery({
queryKey: ["dashboard"],
queryFn: () => dashboardAPI.get(),
staleTime: 2 * 60 * 1000,
refetchInterval: 5 * 60 * 1000,
});
}

View File

@@ -0,0 +1,32 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { followsAPI } from "../api";
export function useFollows() {
return useQuery({
queryKey: ["follows"],
queryFn: () => followsAPI.list(),
staleTime: 30 * 1000,
});
}
export function useAddFollow() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ type, value }: { type: string; value: string }) =>
followsAPI.add(type, value),
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
});
}
export function useRemoveFollow() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => followsAPI.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
});
}
export function useIsFollowing(type: string, value: string) {
const { data: follows = [] } = useFollows();
return follows.find((f) => f.follow_type === type && f.follow_value === value);
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import { membersAPI } from "../api";
export function useMembers(params?: Record<string, unknown>) {
return useQuery({
queryKey: ["members", params],
queryFn: () => membersAPI.list(params),
staleTime: 10 * 60 * 1000,
});
}
export function useMember(id: string) {
return useQuery({
queryKey: ["member", id],
queryFn: () => membersAPI.get(id),
staleTime: 10 * 60 * 1000,
enabled: !!id,
});
}
export function useMemberBills(id: string) {
return useQuery({
queryKey: ["member-bills", id],
queryFn: () => membersAPI.getBills(id),
staleTime: 5 * 60 * 1000,
enabled: !!id,
});
}

103
frontend/lib/types.ts Normal file
View File

@@ -0,0 +1,103 @@
export interface Member {
bioguide_id: string;
name: string;
first_name?: string;
last_name?: string;
party?: string;
state?: string;
chamber?: string;
district?: string;
photo_url?: string;
}
export interface BriefSchema {
id: number;
summary?: string;
key_points?: string[];
risks?: string[];
deadlines?: { date: string | null; description: string }[];
topic_tags?: string[];
llm_provider?: string;
llm_model?: string;
created_at?: string;
}
export interface TrendScore {
score_date: string;
newsapi_count: number;
gnews_count: number;
gtrends_score: number;
composite_score: number;
}
export interface BillAction {
id: number;
action_date?: string;
action_text?: string;
action_type?: string;
chamber?: string;
}
export interface NewsArticle {
id: number;
source?: string;
headline?: string;
url?: string;
published_at?: string;
relevance_score?: number;
}
export interface Bill {
bill_id: string;
congress_number: number;
bill_type: string;
bill_number: number;
title?: string;
short_title?: string;
introduced_date?: string;
latest_action_date?: string;
latest_action_text?: string;
status?: string;
chamber?: string;
congress_url?: string;
sponsor?: Member;
latest_brief?: BriefSchema;
latest_trend?: TrendScore;
updated_at?: string;
}
export interface BillDetail extends Bill {
actions: BillAction[];
news_articles: NewsArticle[];
trend_scores: TrendScore[];
briefs: BriefSchema[];
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
per_page: number;
pages: number;
}
export interface Follow {
id: number;
follow_type: "bill" | "member" | "topic";
follow_value: string;
created_at: string;
}
export interface DashboardData {
feed: Bill[];
trending: Bill[];
follows: { bills: number; members: number; topics: number };
}
export interface SettingsData {
llm_provider: string;
llm_model: string;
congress_poll_interval_minutes: number;
newsapi_enabled: boolean;
pytrends_enabled: boolean;
}

52
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,52 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date?: string | null): string {
if (!date) return "—";
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function billLabel(billType: string, billNumber: number): string {
const labels: Record<string, string> = {
hr: "H.R.",
s: "S.",
hjres: "H.J.Res.",
sjres: "S.J.Res.",
hconres: "H.Con.Res.",
sconres: "S.Con.Res.",
hres: "H.Res.",
sres: "S.Res.",
};
return `${labels[billType?.toLowerCase()] ?? billType?.toUpperCase()} ${billNumber}`;
}
export function partyColor(party?: string): string {
if (!party) return "text-muted-foreground";
const p = party.toLowerCase();
if (p.includes("democrat") || p === "d") return "text-blue-500";
if (p.includes("republican") || p === "r") return "text-red-500";
return "text-yellow-500";
}
export function partyBadgeColor(party?: string): string {
if (!party) return "bg-muted text-muted-foreground";
const p = party.toLowerCase();
if (p.includes("democrat") || p === "d") return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
if (p.includes("republican") || p === "r") return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
}
export function trendColor(score?: number): string {
if (!score) return "text-muted-foreground";
if (score >= 70) return "text-red-500";
if (score >= 40) return "text-yellow-500";
return "text-green-500";
}

13
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "bioguide.congress.gov" },
{ protocol: "https", hostname: "www.congress.gov" },
],
},
};
export default nextConfig;

48
frontend/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "civicstack-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"axios": "^1.7.9",
"recharts": "^2.13.3",
"next-themes": "^0.4.3",
"lucide-react": "^0.468.0",
"date-fns": "^4.1.0",
"zustand": "^5.0.2",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.5",
"class-variance-authority": "^0.7.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.1"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "^15.2.0",
"tailwindcss": "^3.4.16",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,53 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}