Initial commit
This commit is contained in:
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal 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"]
|
||||
93
frontend/app/bills/[id]/page.tsx
Normal file
93
frontend/app/bills/[id]/page.tsx
Normal 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
109
frontend/app/bills/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
frontend/app/following/page.tsx
Normal file
88
frontend/app/following/page.tsx
Normal 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
55
frontend/app/globals.css
Normal 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
35
frontend/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/app/members/[id]/page.tsx
Normal file
55
frontend/app/members/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
frontend/app/members/page.tsx
Normal file
92
frontend/app/members/page.tsx
Normal 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
89
frontend/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/app/providers.tsx
Normal file
27
frontend/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
frontend/app/settings/page.tsx
Normal file
188
frontend/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
frontend/app/topics/page.tsx
Normal file
61
frontend/app/topics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
frontend/components/bills/AIBriefCard.tsx
Normal file
105
frontend/components/bills/AIBriefCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
frontend/components/bills/ActionTimeline.tsx
Normal file
47
frontend/components/bills/ActionTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/components/bills/NewsPanel.tsx
Normal file
46
frontend/components/bills/NewsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/components/bills/TrendChart.tsx
Normal file
86
frontend/components/bills/TrendChart.tsx
Normal 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: 0–100 composite</span>
|
||||
<span>NewsAPI + Google News + Trends</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/components/shared/BillCard.tsx
Normal file
81
frontend/components/shared/BillCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend/components/shared/FollowButton.tsx
Normal file
44
frontend/components/shared/FollowButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/components/shared/Sidebar.tsx
Normal file
63
frontend/components/shared/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/components/shared/ThemeToggle.tsx
Normal file
19
frontend/components/shared/ThemeToggle.tsx
Normal 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
86
frontend/lib/api.ts
Normal 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),
|
||||
};
|
||||
46
frontend/lib/hooks/useBills.ts
Normal file
46
frontend/lib/hooks/useBills.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
11
frontend/lib/hooks/useDashboard.ts
Normal file
11
frontend/lib/hooks/useDashboard.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
32
frontend/lib/hooks/useFollows.ts
Normal file
32
frontend/lib/hooks/useFollows.ts
Normal 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);
|
||||
}
|
||||
28
frontend/lib/hooks/useMembers.ts
Normal file
28
frontend/lib/hooks/useMembers.ts
Normal 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
103
frontend/lib/types.ts
Normal 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
52
frontend/lib/utils.ts
Normal 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
13
frontend/next.config.ts
Normal 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
48
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
53
frontend/tailwind.config.ts
Normal file
53
frontend/tailwind.config.ts
Normal 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
22
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user