feat: PocketVeto v1.0.0 — initial public release
Self-hosted US Congress monitoring platform with AI policy briefs, bill/member/topic follows, ntfy + RSS + email notifications, alignment scoring, collections, and draft-letter generator. Authored by: Jack Levy
This commit is contained in:
163
frontend/app/alignment/page.tsx
Normal file
163
frontend/app/alignment/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { alignmentAPI } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import type { AlignmentScore } from "@/lib/types";
|
||||
|
||||
function partyColor(party?: string) {
|
||||
if (!party) return "bg-muted text-muted-foreground";
|
||||
const p = party.toLowerCase();
|
||||
if (p.includes("republican") || p === "r") return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400";
|
||||
if (p.includes("democrat") || p === "d") return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
function AlignmentBar({ pct }: { pct: number }) {
|
||||
const color =
|
||||
pct >= 66 ? "bg-emerald-500" : pct >= 33 ? "bg-amber-500" : "bg-red-500";
|
||||
return (
|
||||
<div className="flex-1 h-1.5 bg-muted rounded overflow-hidden">
|
||||
<div className={`h-full rounded ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({ member }: { member: AlignmentScore }) {
|
||||
const pct = member.alignment_pct;
|
||||
return (
|
||||
<Link
|
||||
href={`/members/${member.bioguide_id}`}
|
||||
className="flex items-center gap-3 py-3 hover:bg-accent/50 rounded-md px-2 -mx-2 transition-colors"
|
||||
>
|
||||
{member.photo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={member.photo_url}
|
||||
alt={member.name}
|
||||
className="w-9 h-9 rounded-full object-cover shrink-0 border border-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center shrink-0 border border-border text-xs font-medium text-muted-foreground">
|
||||
{member.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">{member.name}</span>
|
||||
<span className="text-sm font-mono font-semibold shrink-0">
|
||||
{pct != null ? `${Math.round(pct)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{member.party && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${partyColor(member.party)}`}>
|
||||
{member.party.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
{member.state && (
|
||||
<span className="text-xs text-muted-foreground">{member.state}</span>
|
||||
)}
|
||||
{pct != null && <AlignmentBar pct={pct} />}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{member.aligned} aligned · {member.opposed} opposed · {member.total} overlapping vote{member.total !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlignmentPage() {
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["alignment"],
|
||||
queryFn: () => alignmentAPI.get(),
|
||||
enabled: !!currentUser,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<div className="text-center py-20 space-y-3">
|
||||
<p className="text-muted-foreground">Sign in to see your representation alignment.</p>
|
||||
<Link href="/login" className="text-sm text-primary hover:underline">Sign in →</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-20 text-muted-foreground text-sm">Loading alignment data…</div>;
|
||||
}
|
||||
|
||||
const members = data?.members ?? [];
|
||||
const hasStance = (data?.total_bills_with_stance ?? 0) > 0;
|
||||
const hasFollowedMembers = members.length > 0 || (data?.total_bills_with_votes ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Representation Alignment</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
How often do your followed members vote with your bill positions?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="bg-card border border-border rounded-lg p-4 text-sm space-y-1.5">
|
||||
<p className="font-medium">How this works</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
For every bill you follow with <strong>Pocket Boost</strong> or <strong>Pocket Veto</strong>, we check
|
||||
how each of your followed members voted on that bill. A Yea vote on a boosted bill counts as
|
||||
aligned; a Nay vote on a vetoed bill counts as aligned. All other combinations count as opposed.
|
||||
Not Voting and Present are excluded.
|
||||
</p>
|
||||
{data && (
|
||||
<p className="text-xs text-muted-foreground pt-1">
|
||||
{data.total_bills_with_stance} bill{data.total_bills_with_stance !== 1 ? "s" : ""} with a stance ·{" "}
|
||||
{data.total_bills_with_votes} had roll-call votes
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty states */}
|
||||
{!hasStance && (
|
||||
<div className="text-center py-12 text-muted-foreground space-y-2">
|
||||
<p className="text-sm">No bill stances yet.</p>
|
||||
<p className="text-xs">
|
||||
Follow some bills with{" "}
|
||||
<Link href="/bills" className="text-primary hover:underline">Pocket Boost or Pocket Veto</Link>{" "}
|
||||
to start tracking alignment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasStance && members.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground space-y-2">
|
||||
<p className="text-sm">No overlapping votes found yet.</p>
|
||||
<p className="text-xs">
|
||||
Make sure you're{" "}
|
||||
<Link href="/members" className="text-primary hover:underline">following some members</Link>
|
||||
, and that those members have voted on bills you've staked a position on.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member list */}
|
||||
{members.length > 0 && (
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="divide-y divide-border">
|
||||
{members.map((m) => (
|
||||
<MemberRow key={m.bioguide_id} member={m} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
frontend/app/bills/[id]/page.tsx
Normal file
233
frontend/app/bills/[id]/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, ExternalLink, FileX, Tag, User } from "lucide-react";
|
||||
import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { BriefPanel } from "@/components/bills/BriefPanel";
|
||||
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
|
||||
import { NotesPanel } from "@/components/bills/NotesPanel";
|
||||
import { ActionTimeline } from "@/components/bills/ActionTimeline";
|
||||
import { VotePanel } from "@/components/bills/VotePanel";
|
||||
import { TrendChart } from "@/components/bills/TrendChart";
|
||||
import { NewsPanel } from "@/components/bills/NewsPanel";
|
||||
import { FollowButton } from "@/components/shared/FollowButton";
|
||||
import { CollectionPicker } from "@/components/bills/CollectionPicker";
|
||||
import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
|
||||
import { TOPIC_LABEL, TOPIC_TAGS } from "@/lib/topics";
|
||||
|
||||
const TABS = [
|
||||
{ id: "analysis", label: "Analysis" },
|
||||
{ id: "timeline", label: "Timeline" },
|
||||
{ id: "votes", label: "Votes" },
|
||||
{ id: "notes", label: "Notes" },
|
||||
] as const;
|
||||
type TabId = typeof TABS[number]["id"];
|
||||
|
||||
export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const billId = decodeURIComponent(id);
|
||||
const [activeTab, setActiveTab] = useState<TabId>("analysis");
|
||||
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const { data: bill, isLoading } = useBill(billId);
|
||||
const { data: trendData } = useBillTrend(billId, 30);
|
||||
const { data: newsArticles, refetch: refetchNews } = useBillNews(billId);
|
||||
|
||||
const { data: note } = useQuery({
|
||||
queryKey: ["note", billId],
|
||||
queryFn: () => import("@/lib/api").then((m) => m.notesAPI.get(billId)),
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
const newsRetryRef = useRef(0);
|
||||
useEffect(() => { newsRetryRef.current = 0; }, [billId]);
|
||||
useEffect(() => {
|
||||
if (newsArticles === undefined || newsArticles.length > 0) return;
|
||||
if (newsRetryRef.current >= 3) return;
|
||||
const timer = setTimeout(() => {
|
||||
newsRetryRef.current += 1;
|
||||
refetchNews();
|
||||
}, 6000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [newsArticles]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
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>
|
||||
{bill.chamber && (
|
||||
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", chamberBadgeColor(bill.chamber))}>
|
||||
{bill.chamber}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">{congressLabel(bill.congress_number)}</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 flex items-center gap-3 flex-wrap">
|
||||
{bill.introduced_date && (
|
||||
<span>Introduced: {formatDate(bill.introduced_date)}</span>
|
||||
)}
|
||||
{bill.congress_url && (
|
||||
<a href={bill.congress_url} target="_blank" rel="noopener noreferrer" className="hover:text-primary transition-colors">
|
||||
congress.gov <ExternalLink className="w-3 h-3 inline" />
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<CollectionPicker billId={bill.bill_id} />
|
||||
<FollowButton type="bill" value={bill.bill_id} supportsModes />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
{/* Pinned note always visible above tabs */}
|
||||
{note?.pinned && <NotesPanel billId={bill.bill_id} />}
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-0 border-b border-border">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
|
||||
activeTab === tab.id
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Topic tags — only show tags that have a matching topic page */}
|
||||
{bill.briefs[0]?.topic_tags && bill.briefs[0].topic_tags.filter((t) => TOPIC_TAGS.has(t)).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{bill.briefs[0].topic_tags.filter((t) => TOPIC_TAGS.has(t)).map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/bills?topic=${encodeURIComponent(tag)}`}
|
||||
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full bg-accent text-accent-foreground hover:bg-accent/70 transition-colors"
|
||||
>
|
||||
<Tag className="w-2.5 h-2.5" />
|
||||
{TOPIC_LABEL[tag] ?? tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "analysis" && (
|
||||
<div className="space-y-6">
|
||||
{bill.briefs.length > 0 ? (
|
||||
<>
|
||||
<BriefPanel briefs={bill.briefs} />
|
||||
<DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} />
|
||||
</>
|
||||
) : bill.has_document ? (
|
||||
<div className="bg-card border border-border rounded-lg p-6 text-center space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Analysis pending</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bill text was retrieved but has not yet been analyzed. Check back shortly.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card border border-border rounded-lg p-6 space-y-3">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<FileX className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm font-medium">No bill text published</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
As of {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })},{" "}
|
||||
no official text has been received for{" "}
|
||||
<span className="font-medium">{billLabel(bill.bill_type, bill.bill_number)}</span>.
|
||||
Analysis will be generated automatically once text is published on Congress.gov.
|
||||
</p>
|
||||
{bill.congress_url && (
|
||||
<a
|
||||
href={bill.congress_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Check status on Congress.gov <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "timeline" && (
|
||||
<ActionTimeline
|
||||
actions={bill.actions}
|
||||
latestActionDate={bill.latest_action_date}
|
||||
latestActionText={bill.latest_action_text}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "votes" && (
|
||||
<VotePanel billId={bill.bill_id} alwaysRender />
|
||||
)}
|
||||
|
||||
{activeTab === "notes" && (
|
||||
<NotesPanel billId={bill.bill_id} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TrendChart data={trendData} />
|
||||
<NewsPanel articles={newsArticles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
frontend/app/bills/page.tsx
Normal file
128
frontend/app/bills/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import { useBills } from "@/lib/hooks/useBills";
|
||||
import { BillCard } from "@/components/shared/BillCard";
|
||||
import { TOPICS } from "@/lib/topics";
|
||||
|
||||
const CHAMBERS = ["", "House", "Senate"];
|
||||
|
||||
export default function BillsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [q, setQ] = useState(searchParams.get("q") ?? "");
|
||||
const [chamber, setChamber] = useState(searchParams.get("chamber") ?? "");
|
||||
const [topic, setTopic] = useState(searchParams.get("topic") ?? "");
|
||||
const [hasText, setHasText] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Sync URL params → state so tag/topic links work when already on this page
|
||||
useEffect(() => {
|
||||
setQ(searchParams.get("q") ?? "");
|
||||
setChamber(searchParams.get("chamber") ?? "");
|
||||
setTopic(searchParams.get("topic") ?? "");
|
||||
setPage(1);
|
||||
}, [searchParams]);
|
||||
|
||||
const params = {
|
||||
...(q && { q }),
|
||||
...(chamber && { chamber }),
|
||||
...(topic && { topic }),
|
||||
...(hasText && { has_document: true }),
|
||||
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); 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.map((t) => <option key={t.tag} value={t.tag}>{t.label}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => { setHasText((v) => !v); setPage(1); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border transition-colors ${
|
||||
hasText
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-card border-border text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
title="Show only bills with published text"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Has text
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
252
frontend/app/collections/[id]/page.tsx
Normal file
252
frontend/app/collections/[id]/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Check, Copy, Globe, Lock, Minus, Search, X } from "lucide-react";
|
||||
import { collectionsAPI, billsAPI } from "@/lib/api";
|
||||
import type { Bill } from "@/lib/types";
|
||||
import { billLabel, formatDate } from "@/lib/utils";
|
||||
|
||||
export default function CollectionDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const collectionId = parseInt(id, 10);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [searchQ, setSearchQ] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<Bill[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const { data: collection, isLoading } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: () => collectionsAPI.get(collectionId),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { name?: string; is_public?: boolean }) =>
|
||||
collectionsAPI.update(collectionId, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["collection", collectionId] });
|
||||
qc.invalidateQueries({ queryKey: ["collections"] });
|
||||
setEditingName(false);
|
||||
},
|
||||
});
|
||||
|
||||
const addBillMutation = useMutation({
|
||||
mutationFn: (bill_id: string) => collectionsAPI.addBill(collectionId, bill_id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["collection", collectionId] });
|
||||
qc.invalidateQueries({ queryKey: ["collections"] });
|
||||
},
|
||||
});
|
||||
|
||||
const removeBillMutation = useMutation({
|
||||
mutationFn: (bill_id: string) => collectionsAPI.removeBill(collectionId, bill_id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["collection", collectionId] });
|
||||
qc.invalidateQueries({ queryKey: ["collections"] });
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSearch(q: string) {
|
||||
setSearchQ(q);
|
||||
if (!q.trim()) { setSearchResults([]); return; }
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await billsAPI.list({ q, per_page: 8 });
|
||||
setSearchResults(res.items);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copyShareLink() {
|
||||
if (!collection) return;
|
||||
navigator.clipboard.writeText(`${window.location.origin}/share/collection/${collection.share_token}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
function startRename() {
|
||||
setNameInput(collection?.name ?? "");
|
||||
setEditingName(true);
|
||||
}
|
||||
|
||||
function submitRename(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = nameInput.trim();
|
||||
if (!name || name === collection?.name) { setEditingName(false); return; }
|
||||
updateMutation.mutate({ name });
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-20 text-muted-foreground text-sm">Loading…</div>;
|
||||
}
|
||||
if (!collection) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground">Collection not found.</p>
|
||||
<Link href="/collections" className="text-sm text-primary mt-2 inline-block">← Back to collections</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const collectionBillIds = new Set(collection.bills.map((b) => b.bill_id));
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/collections" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
{editingName ? (
|
||||
<form onSubmit={submitRename} className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => setNameInput(e.target.value)}
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
className="flex-1 px-2 py-1 text-lg font-bold bg-background border-b-2 border-primary focus:outline-none"
|
||||
/>
|
||||
<button type="submit" className="p-1 text-primary hover:opacity-70">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditingName(false)} className="p-1 text-muted-foreground hover:opacity-70">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={startRename}
|
||||
className="text-xl font-bold hover:opacity-70 transition-opacity text-left"
|
||||
title="Click to rename"
|
||||
>
|
||||
{collection.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Public/private toggle */}
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ is_public: !collection.is_public })}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border border-border hover:bg-accent transition-colors"
|
||||
>
|
||||
{collection.is_public ? (
|
||||
<><Globe className="w-3 h-3 text-green-500" /> Public</>
|
||||
) : (
|
||||
<><Lock className="w-3 h-3 text-muted-foreground" /> Private</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Copy share link */}
|
||||
<button
|
||||
onClick={copyShareLink}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border border-border hover:bg-accent transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<><Check className="w-3 h-3 text-green-500" /> Link copied!</>
|
||||
) : (
|
||||
<><Copy className="w-3 h-3" /> Copy share link</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{collection.bill_count} {collection.bill_count === 1 ? "bill" : "bills"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add bills search */}
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-card border border-border rounded-lg">
|
||||
<Search className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQ}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search to add bills…"
|
||||
className="flex-1 text-sm bg-transparent focus:outline-none"
|
||||
/>
|
||||
{searching && <span className="text-xs text-muted-foreground">Searching…</span>}
|
||||
</div>
|
||||
{searchResults.length > 0 && searchQ && (
|
||||
<div className="absolute top-full left-0 right-0 z-10 mt-1 bg-card border border-border rounded-lg shadow-lg overflow-hidden">
|
||||
{searchResults.map((bill) => {
|
||||
const inCollection = collectionBillIds.has(bill.bill_id);
|
||||
return (
|
||||
<button
|
||||
key={bill.bill_id}
|
||||
onClick={() => {
|
||||
if (!inCollection) {
|
||||
addBillMutation.mutate(bill.bill_id);
|
||||
setSearchQ("");
|
||||
setSearchResults([]);
|
||||
}
|
||||
}}
|
||||
disabled={inCollection}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-default"
|
||||
>
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">
|
||||
{billLabel(bill.bill_type, bill.bill_number)}
|
||||
</span>
|
||||
<span className="text-sm truncate flex-1">
|
||||
{bill.short_title || bill.title || "Untitled"}
|
||||
</span>
|
||||
{inCollection && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">Added</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bill list */}
|
||||
{collection.bills.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p className="text-sm">No bills yet — search to add some.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{collection.bills.map((bill) => (
|
||||
<div
|
||||
key={bill.bill_id}
|
||||
className="flex items-center gap-3 px-4 py-3 bg-card border border-border rounded-lg group"
|
||||
>
|
||||
<Link href={`/bills/${bill.bill_id}`} className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">
|
||||
{billLabel(bill.bill_type, bill.bill_number)}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{bill.short_title || bill.title || "Untitled"}
|
||||
</span>
|
||||
</div>
|
||||
{bill.latest_action_date && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Latest action: {formatDate(bill.latest_action_date)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => removeBillMutation.mutate(bill.bill_id)}
|
||||
className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||
title="Remove from collection"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
frontend/app/collections/page.tsx
Normal file
170
frontend/app/collections/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Bookmark, Plus, Globe, Lock, Trash2 } from "lucide-react";
|
||||
import { collectionsAPI } from "@/lib/api";
|
||||
import { HelpTip } from "@/components/shared/HelpTip";
|
||||
import type { Collection } from "@/lib/types";
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newPublic, setNewPublic] = useState(false);
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const { data: collections, isLoading } = useQuery({
|
||||
queryKey: ["collections"],
|
||||
queryFn: collectionsAPI.list,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: ({ name, is_public }: { name: string; is_public: boolean }) =>
|
||||
collectionsAPI.create(name, is_public),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["collections"] });
|
||||
setNewName("");
|
||||
setNewPublic(false);
|
||||
setShowForm(false);
|
||||
setFormError("");
|
||||
},
|
||||
onError: () => setFormError("Failed to create collection. Try again."),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => collectionsAPI.delete(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["collections"] }),
|
||||
});
|
||||
|
||||
function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) { setFormError("Name is required"); return; }
|
||||
if (name.length > 100) { setFormError("Name must be ≤ 100 characters"); return; }
|
||||
setFormError("");
|
||||
createMutation.mutate({ name, is_public: newPublic });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bookmark className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-xl font-bold">My Collections</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A collection is a named group of bills you curate — like a playlist for legislation.
|
||||
Share any collection via a link; no account required to view.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form
|
||||
onSubmit={handleCreate}
|
||||
className="bg-card border border-border rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Collection name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="e.g. Healthcare Watch"
|
||||
maxLength={100}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newPublic}
|
||||
onChange={(e) => setNewPublic(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Public collection</span>
|
||||
<HelpTip content="Share links work whether or not a collection is public. Marking it public signals it may appear in a future public directory — private collections are invisible to anyone without your link." />
|
||||
</label>
|
||||
{formError && <p className="text-xs text-destructive">{formError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? "Creating…" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowForm(false); setFormError(""); setNewName(""); }}
|
||||
className="px-3 py-1.5 text-sm text-muted-foreground rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">Loading collections…</div>
|
||||
) : !collections || collections.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Bookmark className="w-8 h-8 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No collections yet — create one to start grouping bills.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{collections.map((c: Collection) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="bg-card border border-border rounded-lg px-4 py-3 flex items-center gap-3 group"
|
||||
>
|
||||
<Link href={`/collections/${c.id}`} className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{c.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded shrink-0">
|
||||
{c.bill_count} {c.bill_count === 1 ? "bill" : "bills"}
|
||||
</span>
|
||||
{c.is_public ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
|
||||
<Globe className="w-3 h-3" /> Public
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
<Lock className="w-3 h-3" /> Private
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Created {new Date(c.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${c.name}"? This cannot be undone.`)) {
|
||||
deleteMutation.mutate(c.id);
|
||||
}
|
||||
}}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Delete collection"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
330
frontend/app/following/page.tsx
Normal file
330
frontend/app/following/page.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, ChevronRight, ExternalLink, Heart, Search, X } from "lucide-react";
|
||||
import { useFollows, useRemoveFollow } from "@/lib/hooks/useFollows";
|
||||
import { billsAPI, membersAPI } from "@/lib/api";
|
||||
import { FollowButton } from "@/components/shared/FollowButton";
|
||||
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor } from "@/lib/utils";
|
||||
import type { Follow } from "@/lib/types";
|
||||
|
||||
// ── Bill row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function BillRow({ follow, bill }: { follow: Follow; bill?: ReturnType<typeof billsAPI.get> extends Promise<infer T> ? T : never }) {
|
||||
const label = bill ? billLabel(bill.bill_type, bill.bill_number) : follow.follow_value;
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono font-semibold text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{label}
|
||||
</span>
|
||||
{bill?.chamber && (
|
||||
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", chamberBadgeColor(bill.chamber))}>
|
||||
{bill.chamber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/bills/${follow.follow_value}`}
|
||||
className="text-sm font-medium hover:text-primary transition-colors line-clamp-2 leading-snug"
|
||||
>
|
||||
{bill ? (bill.short_title || bill.title || label) : <span className="text-muted-foreground">Loading…</span>}
|
||||
</Link>
|
||||
{bill?.latest_action_text && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5 line-clamp-1">
|
||||
{bill.latest_action_date && <span>{formatDate(bill.latest_action_date)} — </span>}
|
||||
{bill.latest_action_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FollowButton type="bill" value={follow.follow_value} supportsModes />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Member row ────────────────────────────────────────────────────────────────
|
||||
|
||||
function MemberRow({ follow, member, onRemove }: {
|
||||
follow: Follow;
|
||||
member?: ReturnType<typeof membersAPI.get> extends Promise<infer T> ? T : never;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4 flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
{member?.photo_url ? (
|
||||
<img src={member.photo_url} alt={member.name} className="w-12 h-12 rounded-full object-cover border border-border" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center text-lg font-semibold text-muted-foreground">
|
||||
{member ? member.name[0] : "?"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link href={`/members/${follow.follow_value}`} className="text-sm font-semibold hover:text-primary transition-colors">
|
||||
{member?.name ?? follow.follow_value}
|
||||
</Link>
|
||||
{member?.party && (
|
||||
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", partyBadgeColor(member.party))}>
|
||||
{member.party}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(member?.chamber || member?.state || member?.district) && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{[member.chamber, member.state, member.district ? `District ${member.district}` : null]
|
||||
.filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{member?.official_url && (
|
||||
<a href={member.official_url} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1">
|
||||
Official site <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onRemove} className="text-muted-foreground hover:text-destructive transition-colors p-1 shrink-0" title="Unfollow">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section accordion wrapper ─────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, count, children }: { title: string; count: number; children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-2 mb-3 group"
|
||||
>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
||||
<span className="font-semibold">{title}</span>
|
||||
<span className="text-xs text-muted-foreground font-normal">({count})</span>
|
||||
</button>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FollowingPage() {
|
||||
const { data: follows = [], isLoading } = useFollows();
|
||||
const remove = useRemoveFollow();
|
||||
|
||||
const [billSearch, setBillSearch] = useState("");
|
||||
const [billChamber, setBillChamber] = useState("");
|
||||
const [memberSearch, setMemberSearch] = useState("");
|
||||
const [memberParty, setMemberParty] = useState("");
|
||||
const [topicSearch, setTopicSearch] = useState("");
|
||||
|
||||
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");
|
||||
|
||||
// Batch-fetch bill + member data at page level so filters have access to titles/names.
|
||||
// Uses the same query keys as BillRow/MemberRow — React Query deduplicates, no extra calls.
|
||||
const billQueries = useQueries({
|
||||
queries: bills.map((f) => ({
|
||||
queryKey: ["bill", f.follow_value],
|
||||
queryFn: () => billsAPI.get(f.follow_value),
|
||||
staleTime: 2 * 60 * 1000,
|
||||
})),
|
||||
});
|
||||
|
||||
const memberQueries = useQueries({
|
||||
queries: members.map((f) => ({
|
||||
queryKey: ["member", f.follow_value],
|
||||
queryFn: () => membersAPI.get(f.follow_value),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
})),
|
||||
});
|
||||
|
||||
// Filter bills
|
||||
const filteredBills = bills.filter((f, i) => {
|
||||
const bill = billQueries[i]?.data;
|
||||
if (billChamber && bill?.chamber?.toLowerCase() !== billChamber.toLowerCase()) return false;
|
||||
if (billSearch) {
|
||||
const q = billSearch.toLowerCase();
|
||||
const label = bill ? billLabel(bill.bill_type, bill.bill_number).toLowerCase() : "";
|
||||
const title = (bill?.short_title || bill?.title || "").toLowerCase();
|
||||
const id = f.follow_value.toLowerCase();
|
||||
if (!label.includes(q) && !title.includes(q) && !id.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Filter members
|
||||
const filteredMembers = members.filter((f, i) => {
|
||||
const member = memberQueries[i]?.data;
|
||||
if (memberParty && member?.party !== memberParty) return false;
|
||||
if (memberSearch) {
|
||||
const q = memberSearch.toLowerCase();
|
||||
const name = (member?.name || f.follow_value).toLowerCase();
|
||||
if (!name.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Filter topics
|
||||
const filteredTopics = topics.filter((f) =>
|
||||
!topicSearch || f.follow_value.toLowerCase().includes(topicSearch.toLowerCase())
|
||||
);
|
||||
|
||||
// Unique parties and chambers from loaded data for filter dropdowns
|
||||
const loadedChambers = [...new Set(billQueries.map((q) => q.data?.chamber).filter(Boolean))] as string[];
|
||||
const loadedParties = [...new Set(memberQueries.map((q) => q.data?.party).filter(Boolean))] as string[];
|
||||
|
||||
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>
|
||||
|
||||
{/* Bills */}
|
||||
<Section title="Bills" count={bills.length}>
|
||||
<div className="space-y-3">
|
||||
{/* Search + filter bar */}
|
||||
{bills.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search bills…"
|
||||
value={billSearch}
|
||||
onChange={(e) => setBillSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
{loadedChambers.length > 1 && (
|
||||
<select
|
||||
value={billChamber}
|
||||
onChange={(e) => setBillChamber(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none"
|
||||
>
|
||||
<option value="">All Chambers</option>
|
||||
{loadedChambers.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!bills.length ? (
|
||||
<p className="text-sm text-muted-foreground">No bills followed yet.</p>
|
||||
) : !filteredBills.length ? (
|
||||
<p className="text-sm text-muted-foreground">No bills match your filters.</p>
|
||||
) : (
|
||||
filteredBills.map((f, i) => {
|
||||
const originalIndex = bills.indexOf(f);
|
||||
return <BillRow key={f.id} follow={f} bill={billQueries[originalIndex]?.data} />;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Members */}
|
||||
<Section title="Members" count={members.length}>
|
||||
<div className="space-y-3">
|
||||
{members.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search members…"
|
||||
value={memberSearch}
|
||||
onChange={(e) => setMemberSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
{loadedParties.length > 1 && (
|
||||
<select
|
||||
value={memberParty}
|
||||
onChange={(e) => setMemberParty(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none"
|
||||
>
|
||||
<option value="">All Parties</option>
|
||||
{loadedParties.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!members.length ? (
|
||||
<p className="text-sm text-muted-foreground">No members followed yet.</p>
|
||||
) : !filteredMembers.length ? (
|
||||
<p className="text-sm text-muted-foreground">No members match your filters.</p>
|
||||
) : (
|
||||
filteredMembers.map((f, i) => {
|
||||
const originalIndex = members.indexOf(f);
|
||||
return (
|
||||
<MemberRow
|
||||
key={f.id}
|
||||
follow={f}
|
||||
member={memberQueries[originalIndex]?.data}
|
||||
onRemove={() => remove.mutate(f.id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Topics */}
|
||||
<Section title="Topics" count={topics.length}>
|
||||
<div className="space-y-3">
|
||||
{topics.length > 0 && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search topics…"
|
||||
value={topicSearch}
|
||||
onChange={(e) => setTopicSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!topics.length ? (
|
||||
<p className="text-sm text-muted-foreground">No topics followed yet.</p>
|
||||
) : !filteredTopics.length ? (
|
||||
<p className="text-sm text-muted-foreground">No topics match your search.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredTopics.map((f) => (
|
||||
<div key={f.id} className="bg-card border border-border rounded-lg p-3 flex items-center justify-between">
|
||||
<Link
|
||||
href={`/bills?topic=${f.follow_value}`}
|
||||
className="text-sm font-medium hover:text-primary transition-colors capitalize"
|
||||
>
|
||||
{f.follow_value.replace(/-/g, " ")}
|
||||
</Link>
|
||||
<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>
|
||||
</Section>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
353
frontend/app/how-it-works/page.tsx
Normal file
353
frontend/app/how-it-works/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BarChart2,
|
||||
Bell,
|
||||
Bookmark,
|
||||
Calendar,
|
||||
Clock,
|
||||
FileText,
|
||||
Filter,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
ListChecks,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Rss,
|
||||
Shield,
|
||||
Share2,
|
||||
StickyNote,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
function Section({ id, title, icon: Icon, children }: {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="bg-card border border-border rounded-lg p-6 space-y-4 scroll-mt-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({ icon: Icon, color, title, children }: {
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className={`mt-0.5 shrink-0 w-7 h-7 rounded-full flex items-center justify-center ${color}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HowItWorksPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<HelpCircle className="w-5 h-5" /> How it works
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
A quick guide to PocketVeto's features.
|
||||
</p>
|
||||
{/* Jump links */}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{[
|
||||
{ href: "#follow", label: "Following" },
|
||||
{ href: "#collections", label: "Collections" },
|
||||
{ href: "#notifications", label: "Notifications" },
|
||||
{ href: "#briefs", label: "AI Briefs" },
|
||||
{ href: "#votes", label: "Votes" },
|
||||
{ href: "#alignment", label: "Alignment" },
|
||||
{ href: "#notes", label: "Notes" },
|
||||
{ href: "#bills", label: "Bills" },
|
||||
{ href: "#members-topics", label: "Members & Topics" },
|
||||
{ href: "#dashboard", label: "Dashboard" },
|
||||
].map(({ href, label }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="text-xs px-2.5 py-1 bg-muted rounded-full hover:bg-accent transition-colors"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Following */}
|
||||
<Section id="follow" title="Following bills" icon={Heart}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Follow any bill to track it. PocketVeto checks for changes and notifies you through your
|
||||
configured channels. Three modes let you tune the signal to your interest level — each
|
||||
with its own independent set of alert filters.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Item icon={Heart} color="bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400" title="Follow">
|
||||
The standard mode. Default alerts: new bill text, amendments filed, chamber votes,
|
||||
presidential action, and committee reports.
|
||||
</Item>
|
||||
<Item icon={Shield} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Pocket Veto">
|
||||
For bills you oppose and only want to hear about if they gain real traction. Default
|
||||
alerts: chamber votes and presidential action only — no noise from early committee or
|
||||
document activity.
|
||||
</Item>
|
||||
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Pocket Boost">
|
||||
For bills you actively support. Default alerts: everything — new text, amendments,
|
||||
votes, presidential action, committee reports, calendar placement, procedural moves,
|
||||
and committee referrals. Also adds “Find Your Rep” action buttons to push
|
||||
notifications.
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/60 px-4 py-3 space-y-1.5">
|
||||
<p className="text-xs font-medium flex items-center gap-1.5">
|
||||
<Filter className="w-3.5 h-3.5" /> Adjusting alert filters
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
The defaults above are starting points. In{" "}
|
||||
<Link href="/notifications" className="text-primary hover:underline">Notifications → Alert Filters</Link>,
|
||||
each mode has its own tab with eight independently toggleable alert types. For example,
|
||||
a Follow bill where you don't care about committee reports — uncheck it and only
|
||||
that mode is affected. Hit <strong>Load defaults</strong> on any tab to revert to the
|
||||
preset above.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can also follow <strong>members</strong> and <strong>topics</strong>.
|
||||
When a followed member sponsors a bill, or a new bill matches a followed topic, you'll
|
||||
receive a <em>Discovery</em> alert. These have their own independent filter set in{" "}
|
||||
<Link href="/notifications" className="text-primary hover:underline">Notifications → Alert Filters → Discovery</Link>.
|
||||
By default, all followed members and topics trigger notifications — you can mute individual
|
||||
ones without unfollowing them.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Collections */}
|
||||
<Section id="collections" title="Collections" icon={Bookmark}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A collection is a named, curated group of bills — like a playlist for legislation. Use
|
||||
collections to track a policy area, build a watchlist for an advocacy campaign, or share
|
||||
research with colleagues.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Item icon={Bookmark} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Creating a collection">
|
||||
Give it a name (e.g. “Healthcare Watch”) and add bills from any bill detail
|
||||
page using the bookmark icon next to the Follow button.
|
||||
</Item>
|
||||
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing">
|
||||
Every collection has a unique share link. Anyone with the link can view the collection —
|
||||
no account required.
|
||||
</Item>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Public vs. private:</strong> Both have share links. Marking a collection public
|
||||
signals it may appear in a future public directory; private collections are invisible to
|
||||
anyone without your link.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Notifications */}
|
||||
<Section id="notifications" title="Notifications" icon={Bell}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PocketVeto delivers alerts through three independent channels — use any combination.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Item icon={Bell} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Push via ntfy">
|
||||
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a>
|
||||
{" "}is a free, open-source push notification service. Configure a topic URL in{" "}
|
||||
<Link href="/notifications" className="text-primary hover:underline">Notifications</Link>{" "}
|
||||
and receive real-time alerts on any device with the ntfy app.
|
||||
</Item>
|
||||
<Item icon={Mail} color="bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400" title="Email">
|
||||
Receive alerts as plain-text emails. Add your address in{" "}
|
||||
<Link href="/notifications" className="text-primary hover:underline">Notifications → Email</Link>.
|
||||
Every email includes a one-click unsubscribe link, and your address is never used for
|
||||
anything other than bill alerts.
|
||||
</Item>
|
||||
<Item icon={Rss} color="bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400" title="RSS feed">
|
||||
A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader
|
||||
(Feedly, NetNewsWire, etc.). Always real-time, completely independent of the other channels.
|
||||
</Item>
|
||||
<Item icon={Clock} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Quiet hours">
|
||||
Pause push and email notifications during set hours (e.g. 10 PM – 8 AM). Events that
|
||||
arrive during quiet hours are queued and sent as a batch when the window ends.
|
||||
</Item>
|
||||
<Item icon={Calendar} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Digest mode">
|
||||
Instead of one alert per event, receive a single bundled summary on a daily or weekly
|
||||
schedule. Your RSS feed is always real-time regardless of this setting.
|
||||
</Item>
|
||||
<Item icon={MessageSquare} color="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" title="Discovery alerts">
|
||||
Member and topic follows generate Discovery alerts — separate from the bills you follow
|
||||
directly. In{" "}
|
||||
<Link href="/notifications" className="text-primary hover:underline">Alert Filters → Discovery</Link>,
|
||||
you can enable or disable these independently, tune which event types trigger them, and
|
||||
mute specific members or topics without unfollowing them. Each notification includes a
|
||||
“why” line so you always know which follow triggered it.
|
||||
</Item>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* AI Briefs */}
|
||||
<Section id="briefs" title="AI Briefs" icon={FileText}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For bills with published official text, PocketVeto generates a plain-English AI brief
|
||||
automatically — no action needed on your part.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Item icon={FileText} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="What's in a brief">
|
||||
A plain-English summary, key policy points with references to specific bill sections
|
||||
(§ chips you can expand to see the quoted source text), and a risks section that flags
|
||||
potential unintended consequences or contested provisions.
|
||||
</Item>
|
||||
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing a brief">
|
||||
Click the share icon in the brief panel to copy a public link. Anyone can read the
|
||||
brief at that URL — no login required.
|
||||
</Item>
|
||||
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Draft a letter">
|
||||
Use the Draft Letter panel in the Analysis tab to generate a personalised letter to
|
||||
your representative based on the brief's key points.
|
||||
</Item>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Briefs are only generated for bills where GovInfo has published official text. Bills
|
||||
without text show a “No text” badge on their card. When a bill is amended,
|
||||
a new “What Changed” brief is generated automatically alongside the original.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Votes */}
|
||||
<Section id="votes" title="Roll-call votes" icon={ListChecks}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The <strong>Votes</strong> tab on any bill page shows every recorded roll-call vote for
|
||||
that bill, fetched directly from official House and Senate XML sources.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Item icon={ListChecks} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Vote breakdown">
|
||||
Each vote shows the result, chamber, roll number, date, and a visual Yea/Nay bar with
|
||||
exact counts.
|
||||
</Item>
|
||||
<Item icon={Users} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Followed member positions">
|
||||
If any of your followed members voted on the bill, their individual Yea/Nay positions
|
||||
are surfaced directly in the vote row — no need to dig through the full member list.
|
||||
</Item>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Alignment */}
|
||||
<Section id="alignment" title="Representation Alignment" icon={BarChart2}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The <Link href="/alignment" className="text-primary hover:underline">Alignment</Link> page
|
||||
shows how often your followed members vote in line with your stated bill positions.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="How it's calculated">
|
||||
For every bill you follow with Pocket Boost or Pocket Veto, PocketVeto checks how each
|
||||
of your followed members voted. A Yea on a boosted bill counts as aligned; a Nay on a
|
||||
vetoed bill counts as aligned. Not Voting and Present are excluded.
|
||||
</Item>
|
||||
<Item icon={BarChart2} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Alignment score">
|
||||
Each followed member gets an alignment percentage based on all overlapping votes. Members
|
||||
are ranked from most to least aligned with your positions.
|
||||
</Item>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Alignment only appears for members who have actually voted on bills you've stanced.
|
||||
Follow more members and stake positions on more bills to build a fuller picture.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Notes */}
|
||||
<Section id="notes" title="Notes" icon={StickyNote}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a personal note to any bill — visible only to you. Find it in the{" "}
|
||||
<strong>Notes</strong> tab on any bill detail page.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Item icon={StickyNote} color="bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" title="Pinning">
|
||||
Pin a note to float it above the tab bar so it's always visible when you open the
|
||||
bill, regardless of which tab you're on.
|
||||
</Item>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Bills */}
|
||||
<Section id="bills" title="Browsing bills" icon={FileText}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The <Link href="/bills" className="text-primary hover:underline">Bills</Link> page lists
|
||||
all tracked legislation. Use the filters to narrow your search.
|
||||
</p>
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<p><strong className="text-foreground">Search</strong> — matches bill ID, title, and short title.</p>
|
||||
<p><strong className="text-foreground">Chamber</strong> — House or Senate.</p>
|
||||
<p><strong className="text-foreground">Topic</strong> — AI-tagged policy area (healthcare, defense, etc.).</p>
|
||||
<p><strong className="text-foreground">Has text</strong> — show only bills with published official text. On by default.</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Each bill page is organised into four tabs: <strong>Analysis</strong> (AI brief + draft
|
||||
letter), <strong>Timeline</strong> (action history), <strong>Votes</strong> (roll-call
|
||||
records), and <strong>Notes</strong> (your personal note).
|
||||
Topic tags appear just below the tab bar — click any tag to jump to that filtered view.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Members & Topics */}
|
||||
<Section id="members-topics" title="Members & Topics" icon={Users}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse and follow legislators and policy topics independently of specific bills.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Item icon={Users} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Members">
|
||||
The <Link href="/members" className="text-primary hover:underline">Members</Link> page
|
||||
lists all current members of Congress. Each member page shows their sponsored bills,
|
||||
news coverage, voting trend, and — once enough votes are recorded —
|
||||
an <strong>effectiveness score</strong> ranking how often their sponsored bills advance.
|
||||
</Item>
|
||||
<Item icon={Filter} color="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" title="Topics">
|
||||
The <Link href="/topics" className="text-primary hover:underline">Topics</Link> page
|
||||
lists all AI-tagged policy areas. Following a topic sends you a Discovery alert whenever
|
||||
a new bill is tagged with it — useful for staying on top of a policy area without
|
||||
tracking individual bills.
|
||||
</Item>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Dashboard */}
|
||||
<Section id="dashboard" title="Dashboard" icon={TrendingUp}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The <Link href="/" className="text-primary hover:underline">Dashboard</Link> is your
|
||||
personalised home view, split into two areas.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Item icon={Heart} color="bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400" title="Your feed">
|
||||
Bills from your follows — directly followed bills, bills sponsored by followed members,
|
||||
and bills matching followed topics — sorted by latest activity.
|
||||
</Item>
|
||||
<Item icon={TrendingUp} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Trending">
|
||||
The top 10 bills by composite trend score, calculated nightly from news article volume
|
||||
(NewsAPI + Google News) and Google Trends interest. A bill climbing here is getting real
|
||||
public attention regardless of whether you follow it.
|
||||
</Item>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/app/icon.svg
Normal file
11
frontend/app/icon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#1e40af"/>
|
||||
<g stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<line x1="4" y1="28" x2="28" y2="28"/>
|
||||
<line x1="8" y1="24" x2="8" y2="15"/>
|
||||
<line x1="13" y1="24" x2="13" y2="15"/>
|
||||
<line x1="19" y1="24" x2="19" y2="15"/>
|
||||
<line x1="24" y1="24" x2="24" y2="15"/>
|
||||
<polygon points="16,5 27,13 5,13"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 488 B |
30
frontend/app/layout.tsx
Normal file
30
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import { AuthGuard } from "@/components/shared/AuthGuard";
|
||||
|
||||
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>
|
||||
<AuthGuard>
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
90
frontend/app/login/page.tsx
Normal file
90
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { authAPI } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const { access_token, user } = await authAPI.login(email.trim(), password);
|
||||
setAuth(access_token, { id: user.id, email: user.email, is_admin: user.is_admin });
|
||||
router.replace("/");
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
||||
"Login failed. Check your email and password.";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm space-y-6 p-8 border rounded-lg bg-card shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">PocketVeto</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
No account?{" "}
|
||||
<Link href="/register" className="text-primary hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
frontend/app/members/[id]/page.tsx
Normal file
271
frontend/app/members/[id]/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
MapPin,
|
||||
Phone,
|
||||
Globe,
|
||||
Star,
|
||||
FileText,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useMember, useMemberBills, useMemberTrend, useMemberNews } from "@/lib/hooks/useMembers";
|
||||
import { TrendChart } from "@/components/bills/TrendChart";
|
||||
import { NewsPanel } from "@/components/bills/NewsPanel";
|
||||
import { FollowButton } from "@/components/shared/FollowButton";
|
||||
import { BillCard } from "@/components/shared/BillCard";
|
||||
import { cn, partyBadgeColor } from "@/lib/utils";
|
||||
|
||||
function ordinal(n: number) {
|
||||
const s = ["th", "st", "nd", "rd"];
|
||||
const v = n % 100;
|
||||
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
}
|
||||
|
||||
export default function MemberDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const { data: member, isLoading } = useMember(id);
|
||||
const { data: billsData } = useMemberBills(id);
|
||||
const { data: trendData } = useMemberTrend(id, 30);
|
||||
const { data: newsData } = useMemberNews(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>;
|
||||
|
||||
const currentLeadership = member.leadership_json?.filter((l) => l.current);
|
||||
const termsSorted = [...(member.terms_json || [])].reverse();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back */}
|
||||
<Link href="/members" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Members
|
||||
</Link>
|
||||
|
||||
{/* Bio header */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-5">
|
||||
{member.photo_url ? (
|
||||
<Image
|
||||
src={member.photo_url}
|
||||
alt={member.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="rounded-lg object-cover shrink-0 border border-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-lg bg-muted flex items-center justify-center shrink-0 border border-border">
|
||||
<Users className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{member.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{member.party && (
|
||||
<span className={cn("px-2 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
|
||||
{member.party}
|
||||
</span>
|
||||
)}
|
||||
{member.chamber && <span className="text-sm text-muted-foreground">{member.chamber}</span>}
|
||||
{member.state && <span className="text-sm text-muted-foreground">{member.state}</span>}
|
||||
{member.district && <span className="text-sm text-muted-foreground">District {member.district}</span>}
|
||||
{member.birth_year && (
|
||||
<span className="text-sm text-muted-foreground">b. {member.birth_year}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leadership */}
|
||||
{currentLeadership && currentLeadership.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{currentLeadership.map((l, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
<Star className="w-3 h-3" />
|
||||
{l.type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact */}
|
||||
<div className="flex flex-col gap-1.5 text-sm text-muted-foreground">
|
||||
{member.address && (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>{member.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{member.phone && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Phone className="w-3.5 h-3.5 shrink-0" />
|
||||
<a href={`tel:${member.phone}`} className="hover:text-foreground transition-colors">
|
||||
{member.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{member.official_url && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Globe className="w-3.5 h-3.5 shrink-0" />
|
||||
<a href={member.official_url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors truncate max-w-xs">
|
||||
{member.official_url.replace(/^https?:\/\//, "")}
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{member.congress_url && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5 shrink-0" />
|
||||
<a href={member.congress_url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
|
||||
congress.gov profile
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FollowButton type="member" value={member.bioguide_id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Left column */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Sponsored bills */}
|
||||
<div>
|
||||
<h2 className="font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Sponsored Bills
|
||||
{billsData?.total != null && (
|
||||
<span className="text-xs text-muted-foreground font-normal">({billsData.total})</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-4">
|
||||
{/* Public Interest */}
|
||||
<TrendChart data={trendData ?? []} title="Public Interest" />
|
||||
|
||||
{/* News */}
|
||||
<NewsPanel articles={newsData} />
|
||||
|
||||
{/* Legislation stats */}
|
||||
{(member.sponsored_count != null || member.cosponsored_count != null) && (
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold">Legislation</h3>
|
||||
<div className="space-y-2">
|
||||
{member.sponsored_count != null && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Sponsored</span>
|
||||
<span className="font-medium">{member.sponsored_count.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{member.cosponsored_count != null && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Cosponsored</span>
|
||||
<span className="font-medium">{member.cosponsored_count.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Effectiveness Score */}
|
||||
{member.effectiveness_score != null && (
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold">Effectiveness Score</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Score</span>
|
||||
<span className="font-medium">{member.effectiveness_score.toFixed(1)}</span>
|
||||
</div>
|
||||
{member.effectiveness_percentile != null && (
|
||||
<>
|
||||
<div className="h-1.5 bg-muted rounded overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded transition-all ${
|
||||
member.effectiveness_percentile >= 66
|
||||
? "bg-emerald-500"
|
||||
: member.effectiveness_percentile >= 33
|
||||
? "bg-amber-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
style={{ width: `${member.effectiveness_percentile}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{Math.round(member.effectiveness_percentile)}th percentile
|
||||
{member.effectiveness_tier ? ` among ${member.effectiveness_tier} members` : ""}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Measures legislative output: how far sponsored bills travel, bipartisan support, substance, and committee leadership.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service history */}
|
||||
{termsSorted.length > 0 && (
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold">Service History</h3>
|
||||
<div className="space-y-2">
|
||||
{termsSorted.map((term, i) => (
|
||||
<div key={i} className="text-sm border-l-2 border-border pl-3">
|
||||
<div className="font-medium">
|
||||
{term.congress ? `${ordinal(term.congress)} Congress` : ""}
|
||||
{term.startYear && term.endYear
|
||||
? ` (${term.startYear}–${term.endYear})`
|
||||
: term.startYear
|
||||
? ` (${term.startYear}–present)`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[term.chamber, term.partyName, term.stateName].filter(Boolean).join(" · ")}
|
||||
{term.district ? ` · District ${term.district}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All leadership roles */}
|
||||
{member.leadership_json && member.leadership_json.length > 0 && (
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold">Leadership Roles</h3>
|
||||
<div className="space-y-2">
|
||||
{member.leadership_json.map((l, i) => (
|
||||
<div key={i} className="flex items-start justify-between gap-2 text-sm">
|
||||
<span className={l.current ? "font-medium" : "text-muted-foreground"}>{l.type}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{l.congress ? `${ordinal(l.congress)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
frontend/app/members/page.tsx
Normal file
213
frontend/app/members/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { MapPin, Search, Heart } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMembers, useMember } from "@/lib/hooks/useMembers";
|
||||
import { useFollows } from "@/lib/hooks/useFollows";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { FollowButton } from "@/components/shared/FollowButton";
|
||||
import { membersAPI } from "@/lib/api";
|
||||
import { cn, partyBadgeColor } from "@/lib/utils";
|
||||
import type { Member } from "@/lib/types";
|
||||
|
||||
function MemberCard({ member }: { member: Member }) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
{member.photo_url ? (
|
||||
<img src={member.photo_url} alt={member.name} className="w-10 h-10 rounded-full object-cover shrink-0 border border-border" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-sm font-semibold text-muted-foreground shrink-0">
|
||||
{member.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<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 flex-wrap">
|
||||
{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>
|
||||
{(member.phone || member.official_url) && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{member.phone && (
|
||||
<a href={`tel:${member.phone.replace(/\D/g, "")}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
{member.phone}
|
||||
</a>
|
||||
)}
|
||||
{member.official_url && (
|
||||
<a href={member.official_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FollowButton type="member" value={member.bioguide_id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
|
||||
const { data: member } = useMember(bioguideId);
|
||||
if (!member) return null;
|
||||
return <MemberCard member={member} />;
|
||||
}
|
||||
|
||||
export default function MembersPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [chamber, setChamber] = useState("");
|
||||
const [party, setParty] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [zipInput, setZipInput] = useState("");
|
||||
const [submittedZip, setSubmittedZip] = useState("");
|
||||
|
||||
const { data, isLoading } = useMembers({
|
||||
...(q && { q }), ...(chamber && { chamber }), ...(party && { party }),
|
||||
page, per_page: 50,
|
||||
});
|
||||
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const { data: follows } = useFollows();
|
||||
const followedMemberIds = follows?.filter((f) => f.follow_type === "member").map((f) => f.follow_value) ?? [];
|
||||
|
||||
const isValidZip = /^\d{5}$/.test(submittedZip);
|
||||
const { data: myReps, isFetching: repsFetching, error: repsError } = useQuery({
|
||||
queryKey: ["members-by-zip", submittedZip],
|
||||
queryFn: () => membersAPI.byZip(submittedZip),
|
||||
enabled: isValidZip,
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
function handleZipSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmittedZip(zipInput.trim());
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Zip lookup */}
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-primary" />
|
||||
Find your representatives
|
||||
</p>
|
||||
<form onSubmit={handleZipSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={zipInput}
|
||||
onChange={(e) => setZipInput(e.target.value)}
|
||||
placeholder="Enter ZIP code"
|
||||
maxLength={5}
|
||||
className="px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary w-40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!/^\d{5}$/.test(zipInput.trim())}
|
||||
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Find
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{repsFetching && (
|
||||
<p className="text-sm text-muted-foreground">Looking up representatives…</p>
|
||||
)}
|
||||
{repsError && (
|
||||
<p className="text-sm text-destructive">Could not look up representatives. Check your ZIP and try again.</p>
|
||||
)}
|
||||
{isValidZip && !repsFetching && myReps && myReps.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No representatives found for {submittedZip}.</p>
|
||||
)}
|
||||
{myReps && myReps.length > 0 && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<p className="text-xs text-muted-foreground">Representatives for ZIP {submittedZip}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{myReps.map((rep) => (
|
||||
<MemberCard key={rep.bioguide_id} member={rep} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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 by name..."
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); 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>
|
||||
|
||||
{token && followedMemberIds.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-semibold text-sm flex items-center gap-2">
|
||||
<Heart className="w-4 h-4 text-red-500 fill-red-500" />
|
||||
Following ({followedMemberIds.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{followedMemberIds.map((id) => (
|
||||
<FollowedMemberRow key={id} bioguideId={id} />
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border pt-2" />
|
||||
</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-1 sm:grid-cols-2 gap-3">
|
||||
{data?.items?.map((member) => (
|
||||
<MemberCard key={member.bioguide_id} member={member} />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
1151
frontend/app/notifications/page.tsx
Normal file
1151
frontend/app/notifications/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
97
frontend/app/page.tsx
Normal file
97
frontend/app/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { TrendingUp, BookOpen, Flame } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useDashboard } from "@/lib/hooks/useDashboard";
|
||||
import { BillCard } from "@/components/shared/BillCard";
|
||||
import { WelcomeBanner } from "@/components/shared/WelcomeBanner";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isLoading } = useDashboard();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<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>
|
||||
|
||||
<WelcomeBanner />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-20 text-muted-foreground">Loading dashboard...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-8">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
{token ? <BookOpen className="w-4 h-4" /> : <Flame className="w-4 h-4" />}
|
||||
{token ? "Your Feed" : "Most Popular"}
|
||||
{token && 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>
|
||||
{!token ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-dashed px-4 py-3 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sign in to personalise this feed with bills and members you follow.
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Link href="/register" className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
Register
|
||||
</Link>
|
||||
<Link href="/login" className="px-3 py-1.5 text-xs font-medium rounded-md border border-border text-foreground hover:bg-accent transition-colors">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{data?.trending?.length ? (
|
||||
<div className="space-y-3">
|
||||
{data.trending.map((bill) => (
|
||||
<BillCard key={bill.bill_id} bill={bill} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : !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.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
96
frontend/app/register/page.tsx
Normal file
96
frontend/app/register/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { authAPI } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { access_token, user } = await authAPI.register(email.trim(), password);
|
||||
setAuth(access_token, { id: user.id, email: user.email, is_admin: user.is_admin });
|
||||
router.replace("/");
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
||||
"Registration failed. Please try again.";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm space-y-6 p-8 border rounded-lg bg-card shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">PocketVeto</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">Create your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
Password <span className="text-muted-foreground font-normal">(min 8 chars)</span>
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Creating account..." : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
976
frontend/app/settings/page.tsx
Normal file
976
frontend/app/settings/page.tsx
Normal file
@@ -0,0 +1,976 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Settings,
|
||||
Cpu,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Play,
|
||||
Users,
|
||||
Trash2,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Shield,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { settingsAPI, adminAPI, notificationsAPI, type AdminUser, type LLMModel, type ApiHealthResult, alignmentAPI } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
function relativeTime(isoStr: string): string {
|
||||
const diff = Date.now() - new Date(isoStr.endsWith("Z") ? isoStr : isoStr + "Z").getTime();
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const mins = Math.floor((diff % 3_600_000) / 60_000);
|
||||
return hours > 0 ? `${hours}h ${mins}m ago` : `${mins}m ago`;
|
||||
}
|
||||
|
||||
const LLM_PROVIDERS = [
|
||||
{
|
||||
value: "openai",
|
||||
label: "OpenAI",
|
||||
hint: "Requires OPENAI_API_KEY in .env",
|
||||
rateNote: "Free: 3 RPM · Paid tier 1: 500 RPM",
|
||||
modelNote: "Recommended: gpt-4o-mini — excellent JSON quality at ~10× lower cost than gpt-4o",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
label: "Anthropic (Claude)",
|
||||
hint: "Requires ANTHROPIC_API_KEY in .env",
|
||||
rateNote: "Tier 1: 50 RPM · Tier 2: 1,000 RPM",
|
||||
modelNote: "Recommended: claude-sonnet-4-6 — matches Opus quality at ~5× lower cost",
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
label: "Google Gemini",
|
||||
hint: "Requires GEMINI_API_KEY in .env",
|
||||
rateNote: "Free: 15 RPM · Paid: 2,000 RPM",
|
||||
modelNote: "Recommended: gemini-2.0-flash — best value, generous free tier",
|
||||
},
|
||||
{
|
||||
value: "ollama",
|
||||
label: "Ollama (Local)",
|
||||
hint: "Requires Ollama running on host",
|
||||
rateNote: "No API rate limits",
|
||||
modelNote: "Recommended: llama3.1 or mistral for reliable structured JSON output",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
export default function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get(),
|
||||
});
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ["admin-stats"],
|
||||
queryFn: () => adminAPI.getStats(),
|
||||
enabled: !!currentUser?.is_admin,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const [healthTesting, setHealthTesting] = useState(false);
|
||||
const [healthData, setHealthData] = useState<Record<string, ApiHealthResult> | null>(null);
|
||||
const testApiHealth = async () => {
|
||||
setHealthTesting(true);
|
||||
try {
|
||||
const result = await adminAPI.getApiHealth();
|
||||
setHealthData(result as unknown as Record<string, ApiHealthResult>);
|
||||
} finally {
|
||||
setHealthTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: users, isLoading: usersLoading } = useQuery({
|
||||
queryKey: ["admin-users"],
|
||||
queryFn: () => adminAPI.listUsers(),
|
||||
enabled: !!currentUser?.is_admin,
|
||||
});
|
||||
|
||||
const updateSetting = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) => settingsAPI.update(key, value),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings"] }),
|
||||
});
|
||||
|
||||
const deleteUser = useMutation({
|
||||
mutationFn: (id: number) => adminAPI.deleteUser(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
|
||||
});
|
||||
|
||||
const toggleAdmin = useMutation({
|
||||
mutationFn: (id: number) => adminAPI.toggleAdmin(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
|
||||
});
|
||||
|
||||
// Live model list from provider API
|
||||
const { data: modelsData, isFetching: modelsFetching, refetch: refetchModels } = useQuery({
|
||||
queryKey: ["llm-models", settings?.llm_provider],
|
||||
queryFn: () => settingsAPI.listModels(settings!.llm_provider),
|
||||
enabled: !!currentUser?.is_admin && !!settings?.llm_provider,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
const liveModels: LLMModel[] = modelsData?.models ?? [];
|
||||
const modelsError: string | undefined = modelsData?.error;
|
||||
|
||||
// Model picker state
|
||||
const [showCustomModel, setShowCustomModel] = useState(false);
|
||||
const [customModel, setCustomModel] = useState("");
|
||||
useEffect(() => {
|
||||
if (!settings || modelsFetching) return;
|
||||
const inList = liveModels.some((m) => m.id === settings.llm_model);
|
||||
if (!inList && settings.llm_model) {
|
||||
setShowCustomModel(true);
|
||||
setCustomModel(settings.llm_model);
|
||||
} else {
|
||||
setShowCustomModel(false);
|
||||
setCustomModel("");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings?.llm_provider, settings?.llm_model, modelsFetching]);
|
||||
|
||||
const [testResult, setTestResult] = useState<{
|
||||
status: string;
|
||||
detail?: string;
|
||||
reply?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
} | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const [modeTestResults, setModeTestResults] = useState<Record<string, { status: string; detail: string }>>({});
|
||||
const [modeTestRunning, setModeTestRunning] = useState<Record<string, boolean>>({});
|
||||
const runModeTest = async (key: string, mode: string, event_type: string) => {
|
||||
setModeTestRunning((p) => ({ ...p, [key]: true }));
|
||||
try {
|
||||
const result = await notificationsAPI.testFollowMode(mode, event_type);
|
||||
setModeTestResults((p) => ({ ...p, [key]: result }));
|
||||
} catch (e: unknown) {
|
||||
setModeTestResults((p) => ({
|
||||
...p,
|
||||
[key]: { status: "error", detail: e instanceof Error ? e.message : String(e) },
|
||||
}));
|
||||
} finally {
|
||||
setModeTestRunning((p) => ({ ...p, [key]: false }));
|
||||
}
|
||||
};
|
||||
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
|
||||
const [taskStatuses, setTaskStatuses] = useState<Record<string, "running" | "done" | "error">>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||
const [showMaintenance, setShowMaintenance] = useState(false);
|
||||
|
||||
const { data: newsApiQuota, refetch: refetchQuota } = useQuery({
|
||||
queryKey: ["newsapi-quota"],
|
||||
queryFn: () => adminAPI.getNewsApiQuota(),
|
||||
enabled: !!currentUser?.is_admin && !!settings?.newsapi_enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const { data: batchStatus } = useQuery({
|
||||
queryKey: ["llm-batch-status"],
|
||||
queryFn: () => adminAPI.getLlmBatchStatus(),
|
||||
enabled: !!currentUser?.is_admin,
|
||||
refetchInterval: (query) => query.state.data?.status === "processing" ? 30_000 : false,
|
||||
});
|
||||
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
const [cacheClearResult, setCacheClearResult] = useState<string | null>(null);
|
||||
const clearGnewsCache = async () => {
|
||||
setClearingCache(true);
|
||||
setCacheClearResult(null);
|
||||
try {
|
||||
const result = await adminAPI.clearGnewsCache();
|
||||
setCacheClearResult(`Cleared ${result.cleared} cached entries`);
|
||||
} catch (e: unknown) {
|
||||
setCacheClearResult(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setClearingCache(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 pollTaskStatus = async (name: string, taskId: string) => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
try {
|
||||
const data = await adminAPI.getTaskStatus(taskId);
|
||||
if (["SUCCESS", "FAILURE", "REVOKED"].includes(data.status)) {
|
||||
setTaskStatuses((prev) => ({ ...prev, [name]: data.status === "SUCCESS" ? "done" : "error" }));
|
||||
qc.invalidateQueries({ queryKey: ["admin-stats"] });
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore polling errors */ }
|
||||
}
|
||||
setTaskStatuses((prev) => ({ ...prev, [name]: "error" }));
|
||||
};
|
||||
|
||||
const trigger = async (name: string, fn: () => Promise<{ task_id: string }>) => {
|
||||
const result = await fn();
|
||||
setTaskIds((prev) => ({ ...prev, [name]: result.task_id }));
|
||||
setTaskStatuses((prev) => ({ ...prev, [name]: "running" }));
|
||||
pollTaskStatus(name, result.task_id);
|
||||
};
|
||||
|
||||
if (settingsLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
|
||||
|
||||
if (!currentUser?.is_admin) {
|
||||
return <div className="text-center py-20 text-muted-foreground">Admin access required.</div>;
|
||||
}
|
||||
|
||||
const pct = stats && stats.total_bills > 0
|
||||
? Math.round((stats.briefs_generated / stats.total_bills) * 100)
|
||||
: 0;
|
||||
|
||||
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" /> Admin
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">Manage users, LLM provider, and system settings</p>
|
||||
</div>
|
||||
|
||||
{/* Notifications link */}
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="flex items-center justify-between bg-card border border-border rounded-lg p-4 hover:bg-accent transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-4 h-4 text-muted-foreground group-hover:text-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Notification Settings</div>
|
||||
<div className="text-xs text-muted-foreground">Configure ntfy push alerts and RSS feed per user</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground group-hover:text-foreground">→</span>
|
||||
</Link>
|
||||
|
||||
{/* Follow Mode Notification Testing */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<Bell className="w-4 h-4" /> Follow Mode Notifications
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Requires at least one bill followed and ntfy configured. Tests use your first followed bill.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{([
|
||||
{
|
||||
key: "veto-suppress",
|
||||
mode: "pocket_veto",
|
||||
event_type: "new_document",
|
||||
icon: Shield,
|
||||
label: "Pocket Veto — suppress brief",
|
||||
description: "Sends a new_document event. Dispatcher should silently drop it — no ntfy notification.",
|
||||
expectColor: "text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
{
|
||||
key: "veto-deliver",
|
||||
mode: "pocket_veto",
|
||||
event_type: "bill_updated",
|
||||
icon: Shield,
|
||||
label: "Pocket Veto — deliver milestone",
|
||||
description: "Sends a bill_updated (milestone) event. Dispatcher should allow it and send ntfy.",
|
||||
expectColor: "text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
{
|
||||
key: "boost-deliver",
|
||||
mode: "pocket_boost",
|
||||
event_type: "bill_updated",
|
||||
icon: Zap,
|
||||
label: "Pocket Boost — deliver with actions",
|
||||
description: "Sends a bill_updated event. ntfy notification should include 'View Bill' and 'Find Your Rep' action buttons.",
|
||||
expectColor: "text-green-600 dark:text-green-400",
|
||||
},
|
||||
] as Array<{
|
||||
key: string;
|
||||
mode: string;
|
||||
event_type: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
description: string;
|
||||
expectColor: string;
|
||||
}>).map(({ key, mode, event_type, icon: Icon, label, description }) => {
|
||||
const result = modeTestResults[key];
|
||||
const running = modeTestRunning[key];
|
||||
return (
|
||||
<div key={key} className="flex items-start gap-3 py-3.5">
|
||||
<Icon className="w-4 h-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
{result && (
|
||||
<div className="flex items-start gap-1.5 text-xs mt-1">
|
||||
{result.status === "ok"
|
||||
? <CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-px" />
|
||||
: <XCircle className="w-3.5 h-3.5 text-red-500 shrink-0 mt-px" />}
|
||||
<span className={result.status === "ok" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
|
||||
{result.detail}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => runModeTest(key, mode, event_type)}
|
||||
disabled={running}
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Analysis Status */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4" /> Bill Pipeline
|
||||
<span className="text-xs text-muted-foreground font-normal ml-auto">refreshes every 30s</span>
|
||||
</h2>
|
||||
{stats ? (
|
||||
<>
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{stats.briefs_generated.toLocaleString()} analyzed ({stats.full_briefs} full · {stats.amendment_briefs} amendments)</span>
|
||||
<span>{pct}% of {stats.total_bills.toLocaleString()} bills</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline breakdown table */}
|
||||
<div className="divide-y divide-border text-sm">
|
||||
{[
|
||||
{ label: "Total bills tracked", value: stats.total_bills, color: "text-foreground", icon: "📋" },
|
||||
{ label: "Text published on Congress.gov", value: stats.docs_fetched, color: "text-blue-600 dark:text-blue-400", icon: "📄" },
|
||||
{ label: "No text published yet", value: stats.no_text_bills, color: "text-muted-foreground", icon: "⏳", note: "Normal — bill text appears after committee markup" },
|
||||
{ label: "AI briefs generated", value: stats.briefs_generated, color: "text-green-600 dark:text-green-400", icon: "✅" },
|
||||
{ label: "Pending LLM analysis", value: stats.pending_llm, color: stats.pending_llm > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", icon: "🔄", action: stats.pending_llm > 0 ? "Resume Analysis" : undefined },
|
||||
{ label: "Briefs missing citations", value: stats.uncited_briefs, color: stats.uncited_briefs > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", icon: "⚠️", action: stats.uncited_briefs > 0 ? "Backfill Citations" : undefined },
|
||||
{ label: "Briefs with unlabeled points", value: stats.unlabeled_briefs, color: stats.unlabeled_briefs > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", icon: "🏷️", action: stats.unlabeled_briefs > 0 ? "Backfill Labels" : undefined },
|
||||
].map(({ label, value, color, icon, note, action }) => (
|
||||
<div key={label} className="flex items-center justify-between py-2.5 gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base leading-none shrink-0">{icon}</span>
|
||||
<div>
|
||||
<span className="text-sm">{label}</span>
|
||||
{note && <p className="text-xs text-muted-foreground mt-0.5">{note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className={`font-semibold tabular-nums ${color}`}>{value.toLocaleString()}</span>
|
||||
{action && (
|
||||
<span className="text-xs text-muted-foreground">→ run {action}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Loading stats...</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* User Management */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<Users className="w-4 h-4" /> Users
|
||||
</h2>
|
||||
{usersLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading users...</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{(users ?? []).map((u: AdminUser) => (
|
||||
<div key={u.id} className="flex items-center justify-between py-3 gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{u.email}</span>
|
||||
{u.is_admin && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded font-medium">
|
||||
admin
|
||||
</span>
|
||||
)}
|
||||
{u.id === currentUser.id && (
|
||||
<span className="text-xs text-muted-foreground">(you)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{u.follow_count} follow{u.follow_count !== 1 ? "s" : ""} ·{" "}
|
||||
joined {new Date(u.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{u.id !== currentUser.id && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => toggleAdmin.mutate(u.id)}
|
||||
disabled={toggleAdmin.isPending}
|
||||
title={u.is_admin ? "Remove admin" : "Make admin"}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
{u.is_admin ? <ShieldOff className="w-4 h-4" /> : <ShieldCheck className="w-4 h-4" />}
|
||||
</button>
|
||||
{confirmDelete === u.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { deleteUser.mutate(u.id); setConfirmDelete(null); }}
|
||||
className="text-xs px-2 py-1 bg-destructive text-destructive-foreground rounded hover:bg-destructive/90"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="text-xs px-2 py-1 bg-muted rounded hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(u.id)}
|
||||
title="Delete user"
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
<div className="space-y-2">
|
||||
{LLM_PROVIDERS.map(({ value, label, hint, rateNote, modelNote }) => {
|
||||
const hasKey = settings?.api_keys_configured?.[value] ?? true;
|
||||
return (
|
||||
<label key={value} className={`flex items-start gap-3 ${hasKey ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="provider"
|
||||
value={value}
|
||||
checked={settings?.llm_provider === value}
|
||||
disabled={!hasKey}
|
||||
onChange={() => {
|
||||
updateSetting.mutate({ key: "llm_provider", value });
|
||||
setShowCustomModel(false);
|
||||
setCustomModel("");
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{hasKey ? (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 font-medium">
|
||||
{value === "ollama" ? "local" : "key set"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground font-medium">
|
||||
no key
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{hint}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{rateNote} · {modelNote}</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Model picker — live from provider API */}
|
||||
<div className="space-y-2 pt-3 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Model</label>
|
||||
{modelsFetching && <span className="text-xs text-muted-foreground">Loading models…</span>}
|
||||
{modelsError && !modelsFetching && (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">{modelsError}</span>
|
||||
)}
|
||||
{!modelsFetching && liveModels.length > 0 && (
|
||||
<button onClick={() => refetchModels()} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{liveModels.length > 0 ? (
|
||||
<select
|
||||
value={showCustomModel ? "__custom__" : (settings?.llm_model ?? "")}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "__custom__") {
|
||||
setShowCustomModel(true);
|
||||
setCustomModel(settings?.llm_model ?? "");
|
||||
} else {
|
||||
setShowCustomModel(false);
|
||||
setCustomModel("");
|
||||
updateSetting.mutate({ key: "llm_model", value: e.target.value });
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm bg-background border border-border rounded-md"
|
||||
>
|
||||
{liveModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name !== m.id ? `${m.name} (${m.id})` : m.id}</option>
|
||||
))}
|
||||
<option value="__custom__">Custom model name…</option>
|
||||
</select>
|
||||
) : (
|
||||
!modelsFetching && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{modelsError ? "Could not fetch models — enter a model name manually below." : "No models found."}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{(showCustomModel || (liveModels.length === 0 && !modelsFetching)) && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. gpt-4o or gemini-2.0-flash"
|
||||
value={customModel}
|
||||
onChange={(e) => setCustomModel(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (customModel.trim()) updateSetting.mutate({ key: "llm_model", value: customModel.trim() });
|
||||
}}
|
||||
disabled={!customModel.trim() || updateSetting.isPending}
|
||||
className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Active: <strong>{settings?.llm_provider}</strong> / <strong>{settings?.llm_model}</strong>
|
||||
</p>
|
||||
</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.model} — {testResult.reply}
|
||||
</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>
|
||||
|
||||
{/* Data Sources */}
|
||||
<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>
|
||||
<div className="flex items-center gap-3">
|
||||
{newsApiQuota && (
|
||||
<span className={`text-xs ${newsApiQuota.remaining < 10 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
{newsApiQuota.remaining}/{newsApiQuota.limit} remaining today
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs font-medium ${settings?.newsapi_enabled ? "text-green-500" : "text-muted-foreground"}`}>
|
||||
{settings?.newsapi_enabled ? "Configured" : "Not configured"}
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* API Health */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">External API Health</h2>
|
||||
<button
|
||||
onClick={testApiHealth}
|
||||
disabled={healthTesting}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-muted hover:bg-accent rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${healthTesting ? "animate-spin" : ""}`} />
|
||||
{healthTesting ? "Testing…" : "Run Tests"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{healthData ? (
|
||||
<div className="divide-y divide-border">
|
||||
{[
|
||||
{ key: "congress_gov", label: "Congress.gov API" },
|
||||
{ key: "govinfo", label: "GovInfo API" },
|
||||
{ key: "newsapi", label: "NewsAPI.org" },
|
||||
{ key: "google_news", label: "Google News RSS" },
|
||||
{ key: "rep_lookup", label: "Rep Lookup (Nominatim + TIGERweb)" },
|
||||
].map(({ key, label }) => {
|
||||
const r = healthData[key];
|
||||
if (!r) return null;
|
||||
return (
|
||||
<div key={key} className="flex items-start justify-between py-3 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className={`text-xs mt-0.5 ${
|
||||
r.status === "ok" ? "text-green-600 dark:text-green-400"
|
||||
: r.status === "skipped" ? "text-muted-foreground"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
{r.detail}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{r.latency_ms !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">{r.latency_ms}ms</span>
|
||||
)}
|
||||
{r.status === "ok" && <CheckCircle className="w-4 h-4 text-green-500" />}
|
||||
{r.status === "error" && <XCircle className="w-4 h-4 text-red-500" />}
|
||||
{r.status === "skipped" && <span className="text-xs text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click Run Tests to check connectivity to each external data source.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Manual Controls */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<h2 className="font-semibold">Manual Controls</h2>
|
||||
|
||||
{(() => {
|
||||
type ControlItem = {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fn: () => Promise<{ task_id: string }>;
|
||||
status: "ok" | "needed" | "on-demand";
|
||||
count?: number;
|
||||
countLabel?: string;
|
||||
};
|
||||
|
||||
const renderRow = ({ key, name, description, fn, status, count, countLabel }: ControlItem) => (
|
||||
<div key={key} className="flex items-start gap-3 py-3.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-full mt-1 shrink-0 ${
|
||||
status === "ok" ? "bg-green-500"
|
||||
: status === "needed" ? "bg-red-500"
|
||||
: "bg-border"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{name}</span>
|
||||
{taskStatuses[key] === "running" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
running
|
||||
{taskIds[key] && (
|
||||
<code className="font-mono opacity-60">{taskIds[key].slice(0, 8)}…</code>
|
||||
)}
|
||||
</span>
|
||||
) : taskStatuses[key] === "done" ? (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">✓ Complete</span>
|
||||
) : taskStatuses[key] === "error" ? (
|
||||
<span className="text-xs text-red-600 dark:text-red-400">✗ Failed</span>
|
||||
) : status === "ok" ? (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">✓ Up to date</span>
|
||||
) : status === "needed" && count !== undefined && count > 0 ? (
|
||||
<span className="text-xs text-red-600 dark:text-red-400">
|
||||
⚠ {count.toLocaleString()} {countLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => trigger(key, fn)}
|
||||
disabled={taskStatuses[key] === "running"}
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{taskStatuses[key] === "running" ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Clear RSS cache — inline action (returns count, not task_id)
|
||||
const ClearCacheRow = (
|
||||
<div className="flex items-start gap-3 py-3.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full mt-1 shrink-0 bg-border" />
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">Clear Google News Cache</span>
|
||||
{cacheClearResult && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">✓ {cacheClearResult}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Flush the 2-hour Google News RSS cache so fresh articles are fetched on the next trend scoring or news run.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearGnewsCache}
|
||||
disabled={clearingCache}
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{clearingCache ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const recurring: ControlItem[] = [
|
||||
{
|
||||
key: "poll",
|
||||
name: "Trigger Poll",
|
||||
description: "Check Congress.gov for newly introduced or updated bills. Runs automatically on a schedule — use this to force an immediate sync.",
|
||||
fn: adminAPI.triggerPoll,
|
||||
status: "on-demand",
|
||||
},
|
||||
{
|
||||
key: "members",
|
||||
name: "Sync Members",
|
||||
description: "Refresh all member profiles from Congress.gov including biography, current term, leadership roles, and contact information.",
|
||||
fn: adminAPI.triggerMemberSync,
|
||||
status: "on-demand",
|
||||
},
|
||||
{
|
||||
key: "trends",
|
||||
name: "Calculate Trends",
|
||||
description: "Score bill and member newsworthiness by counting recent news headlines and Google search interest. Updates the trend charts.",
|
||||
fn: adminAPI.triggerTrendScores,
|
||||
status: "on-demand",
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
name: "Fetch Bill Actions",
|
||||
description: "Download the full legislative history (votes, referrals, amendments) for recently active bills and populate the timeline view.",
|
||||
fn: adminAPI.triggerFetchActions,
|
||||
status: "on-demand",
|
||||
},
|
||||
{
|
||||
key: "resume",
|
||||
name: "Resume Analysis",
|
||||
description: "Restart AI brief generation for bills where processing stalled or failed (e.g. after an LLM quota outage). Also re-queues document fetching for bills that have no text yet.",
|
||||
fn: adminAPI.resumeAnalysis,
|
||||
status: stats ? (stats.pending_llm > 0 ? "needed" : "on-demand") : "on-demand",
|
||||
count: stats?.pending_llm,
|
||||
countLabel: "bills pending analysis",
|
||||
},
|
||||
{
|
||||
key: "weekly-digest",
|
||||
name: "Send Weekly Digest",
|
||||
description: "Immediately dispatch the weekly bill activity summary to all users who have ntfy or RSS enabled and at least one bill followed. Runs automatically every Monday at 8:30 AM UTC.",
|
||||
fn: adminAPI.triggerWeeklyDigest,
|
||||
status: "on-demand",
|
||||
},
|
||||
];
|
||||
|
||||
if (settings?.llm_provider === "openai" || settings?.llm_provider === "anthropic") {
|
||||
recurring.push({
|
||||
key: "llm-batch",
|
||||
name: "Submit LLM Batch (50% off)",
|
||||
description: "Send all unbriefed documents to the Batch API for overnight processing at half the token cost. Returns within seconds — results are imported automatically every 30 minutes via the background poller.",
|
||||
fn: adminAPI.submitLlmBatch,
|
||||
status: "on-demand",
|
||||
});
|
||||
}
|
||||
|
||||
const maintenance: ControlItem[] = [
|
||||
{
|
||||
key: "cosponsors",
|
||||
name: "Backfill Co-sponsors",
|
||||
description: "Fetch co-sponsor lists from Congress.gov for all bills. Required for bipartisan multiplier in effectiveness scoring.",
|
||||
fn: adminAPI.backfillCosponsors,
|
||||
status: "on-demand",
|
||||
},
|
||||
{
|
||||
key: "categories",
|
||||
name: "Classify Bill Categories",
|
||||
description: "Run a lightweight LLM call on each bill to classify it as substantive, commemorative, or administrative. Used to weight effectiveness scores.",
|
||||
fn: adminAPI.backfillCategories,
|
||||
status: "on-demand",
|
||||
},
|
||||
{
|
||||
key: "effectiveness",
|
||||
name: "Calculate Effectiveness Scores",
|
||||
description: "Score all members by legislative output, bipartisanship, bill substance, and committee leadership. Runs automatically nightly at 5 AM UTC.",
|
||||
fn: adminAPI.calculateEffectiveness,
|
||||
status: "on-demand",
|
||||
},
|
||||
{
|
||||
key: "backfill-actions",
|
||||
name: "Backfill All Action Histories",
|
||||
description: "One-time catch-up: fetch action histories for all bills that were imported before this feature existed.",
|
||||
fn: adminAPI.backfillAllActions,
|
||||
status: stats ? (stats.bills_missing_actions > 0 ? "needed" : "ok") : "on-demand",
|
||||
count: stats?.bills_missing_actions,
|
||||
countLabel: "bills missing action history",
|
||||
},
|
||||
{
|
||||
key: "sponsors",
|
||||
name: "Backfill Sponsors",
|
||||
description: "Link bill sponsors that weren't captured during the initial import. Safe to re-run — skips bills that already have a sponsor.",
|
||||
fn: adminAPI.backfillSponsors,
|
||||
status: stats ? (stats.bills_missing_sponsor > 0 ? "needed" : "ok") : "on-demand",
|
||||
count: stats?.bills_missing_sponsor,
|
||||
countLabel: "bills missing sponsor",
|
||||
},
|
||||
{
|
||||
key: "metadata",
|
||||
name: "Backfill Dates & Links",
|
||||
description: "Fill in missing introduced dates, chamber assignments, and congress.gov links by re-fetching bill detail from Congress.gov.",
|
||||
fn: adminAPI.backfillMetadata,
|
||||
status: stats ? (stats.bills_missing_metadata > 0 ? "needed" : "ok") : "on-demand",
|
||||
count: stats?.bills_missing_metadata,
|
||||
countLabel: "bills missing metadata",
|
||||
},
|
||||
{
|
||||
key: "citations",
|
||||
name: "Backfill Citations",
|
||||
description: "Regenerate AI briefs created before inline source citations were added. Deletes the old brief and re-runs LLM analysis using already-stored bill text.",
|
||||
fn: adminAPI.backfillCitations,
|
||||
status: stats ? (stats.uncited_briefs > 0 ? "needed" : "ok") : "on-demand",
|
||||
count: stats?.uncited_briefs,
|
||||
countLabel: "briefs need regeneration",
|
||||
},
|
||||
{
|
||||
key: "labels",
|
||||
name: "Backfill Fact/Inference Labels",
|
||||
description: "Classify existing cited brief points as fact or inference. One compact LLM call per brief — no re-generation of summaries or citations.",
|
||||
fn: adminAPI.backfillLabels,
|
||||
status: stats ? (stats.unlabeled_briefs > 0 ? "needed" : "ok") : "on-demand",
|
||||
count: stats?.unlabeled_briefs,
|
||||
countLabel: "briefs with unlabeled points",
|
||||
},
|
||||
];
|
||||
|
||||
const maintenanceNeeded = maintenance.some((m) => m.status === "needed");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y divide-border">
|
||||
{recurring.map(renderRow)}
|
||||
{batchStatus?.status === "processing" && (
|
||||
<div className="py-2 pl-6 text-xs text-muted-foreground">
|
||||
Batch in progress · {batchStatus.doc_count} documents · submitted {relativeTime(batchStatus.submitted_at!)}
|
||||
</div>
|
||||
)}
|
||||
{ClearCacheRow}
|
||||
</div>
|
||||
|
||||
{/* Maintenance subsection */}
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowMaintenance((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Wrench className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
Maintenance
|
||||
{maintenanceNeeded && (
|
||||
<span className="text-xs font-normal text-red-600 dark:text-red-400">⚠ action needed</span>
|
||||
)}
|
||||
</span>
|
||||
{showMaintenance
|
||||
? <ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
: <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showMaintenance && (
|
||||
<div className="divide-y divide-border px-4">
|
||||
{maintenance.map(renderRow)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/app/share/brief/[token]/page.tsx
Normal file
78
frontend/app/share/brief/[token]/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, Landmark } from "lucide-react";
|
||||
import { shareAPI } from "@/lib/api";
|
||||
import { AIBriefCard } from "@/components/bills/AIBriefCard";
|
||||
import { billLabel } from "@/lib/utils";
|
||||
|
||||
export default function SharedBriefPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = use(params);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["share-brief", token],
|
||||
queryFn: () => shareAPI.getBrief(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Minimal header */}
|
||||
<header className="border-b border-border bg-card px-6 py-3 flex items-center gap-2">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
<Link href="/" className="font-semibold text-sm hover:opacity-70 transition-opacity">
|
||||
PocketVeto
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||
{isLoading && (
|
||||
<div className="text-center py-20 text-muted-foreground text-sm">Loading…</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground">Brief not found or link is invalid.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Bill label + title */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-sm font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
{billLabel(data.bill.bill_type, data.bill.bill_number)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold leading-snug">
|
||||
{data.bill.short_title || data.bill.title || "Untitled Bill"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Full brief */}
|
||||
<AIBriefCard brief={data.brief} />
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Link
|
||||
href={`/bills/${data.bill.bill_id}`}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-border hover:bg-accent transition-colors"
|
||||
>
|
||||
View full bill page <ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Track this bill on PocketVeto →
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/app/share/collection/[token]/page.tsx
Normal file
94
frontend/app/share/collection/[token]/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Landmark } from "lucide-react";
|
||||
import { shareAPI } from "@/lib/api";
|
||||
import type { Bill } from "@/lib/types";
|
||||
import { billLabel, formatDate } from "@/lib/utils";
|
||||
|
||||
export default function SharedCollectionPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = use(params);
|
||||
|
||||
const { data: collection, isLoading, isError } = useQuery({
|
||||
queryKey: ["share-collection", token],
|
||||
queryFn: () => shareAPI.getCollection(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Minimal header */}
|
||||
<header className="border-b border-border bg-card px-6 py-3 flex items-center gap-2">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
<Link href="/" className="font-semibold text-sm hover:opacity-70 transition-opacity">
|
||||
PocketVeto
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||
{isLoading && (
|
||||
<div className="text-center py-20 text-muted-foreground text-sm">Loading…</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground">Collection not found or link is invalid.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collection && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{collection.name}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{collection.bill_count} {collection.bill_count === 1 ? "bill" : "bills"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bill list */}
|
||||
{collection.bills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No bills in this collection.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{collection.bills.map((bill: Bill) => (
|
||||
<Link
|
||||
key={bill.bill_id}
|
||||
href={`/bills/${bill.bill_id}`}
|
||||
className="block bg-card border border-border rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">
|
||||
{billLabel(bill.bill_type, bill.bill_number)}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{bill.short_title || bill.title || "Untitled"}
|
||||
</span>
|
||||
</div>
|
||||
{bill.latest_action_date && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Latest action: {formatDate(bill.latest_action_date)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Follow these bills on PocketVeto →
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
frontend/app/topics/page.tsx
Normal file
39
frontend/app/topics/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Tags } from "lucide-react";
|
||||
import { FollowButton } from "@/components/shared/FollowButton";
|
||||
import { TOPICS } from "@/lib/topics";
|
||||
|
||||
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-1 sm: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