Initial commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user