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:
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN mkdir -p public && npm run build
|
||||
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
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>
|
||||
);
|
||||
}
|
||||
174
frontend/components/bills/AIBriefCard.tsx
Normal file
174
frontend/components/bills/AIBriefCard.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, CheckCircle, Clock, Cpu, ExternalLink } from "lucide-react";
|
||||
import { BriefSchema, CitedPoint } from "@/lib/types";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
interface AIBriefCardProps {
|
||||
brief?: BriefSchema | null;
|
||||
}
|
||||
|
||||
function isCited(p: string | CitedPoint): p is CitedPoint {
|
||||
return typeof p === "object" && p !== null && "text" in p;
|
||||
}
|
||||
|
||||
interface CitedItemProps {
|
||||
point: string | CitedPoint;
|
||||
icon: React.ReactNode;
|
||||
govinfo_url?: string;
|
||||
openKey: string;
|
||||
activeKey: string | null;
|
||||
setActiveKey: (key: string | null) => void;
|
||||
}
|
||||
|
||||
function CitedItem({ point, icon, govinfo_url, openKey, activeKey, setActiveKey }: CitedItemProps) {
|
||||
const cited = isCited(point);
|
||||
const isOpen = activeKey === openKey;
|
||||
|
||||
return (
|
||||
<li className="text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-1">{cited ? point.text : point}</span>
|
||||
{cited && point.label === "inference" && (
|
||||
<span
|
||||
title="This point is an analytical interpretation, not a literal statement from the bill text"
|
||||
className="shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-border text-muted-foreground font-sans leading-none mt-0.5"
|
||||
>
|
||||
Inferred
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cited && (
|
||||
<button
|
||||
onClick={() => setActiveKey(isOpen ? null : openKey)}
|
||||
title={isOpen ? "Hide source" : "View source"}
|
||||
className={`text-left text-xs px-1.5 py-0.5 rounded font-mono leading-snug transition-colors ${
|
||||
isOpen
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
§ {point.citation}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cited && isOpen && (
|
||||
<div className="mt-2 ml-5 rounded-md border border-border bg-muted/40 p-3 space-y-2">
|
||||
<blockquote className="text-xs text-muted-foreground italic leading-relaxed border-l-2 border-primary pl-3">
|
||||
"{point.quote}"
|
||||
</blockquote>
|
||||
{govinfo_url && (
|
||||
<a
|
||||
href={govinfo_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
View source <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIBriefCard({ brief }: AIBriefCardProps) {
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
|
||||
if (!brief) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold">AI Analysis</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Analysis not yet generated. It will appear once the bill text has been processed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4 text-primary" />
|
||||
<h2 className="font-semibold">AI Analysis</h2>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{brief.llm_provider}/{brief.llm_model} · {formatDate(brief.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{brief.summary && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Summary</h3>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-line">{brief.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{brief.key_points && brief.key_points.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Key Points</h3>
|
||||
<ul className="space-y-2">
|
||||
{brief.key_points.map((point, i) => (
|
||||
<CitedItem
|
||||
key={i}
|
||||
point={point}
|
||||
icon={<CheckCircle className="w-3.5 h-3.5 text-green-500" />}
|
||||
govinfo_url={brief.govinfo_url}
|
||||
openKey={`kp-${i}`}
|
||||
activeKey={activeKey}
|
||||
setActiveKey={setActiveKey}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{brief.risks && brief.risks.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Risks & Concerns</h3>
|
||||
<ul className="space-y-2">
|
||||
{brief.risks.map((risk, i) => (
|
||||
<CitedItem
|
||||
key={i}
|
||||
point={risk}
|
||||
icon={<AlertTriangle className="w-3.5 h-3.5 text-yellow-500" />}
|
||||
govinfo_url={brief.govinfo_url}
|
||||
openKey={`risk-${i}`}
|
||||
activeKey={activeKey}
|
||||
setActiveKey={setActiveKey}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{brief.deadlines && brief.deadlines.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Deadlines</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{brief.deadlines.map((d, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<Clock className="w-3.5 h-3.5 mt-0.5 text-blue-500 shrink-0" />
|
||||
<span>
|
||||
{d.date ? <strong>{formatDate(d.date)}: </strong> : ""}
|
||||
{d.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
frontend/components/bills/ActionTimeline.tsx
Normal file
68
frontend/components/bills/ActionTimeline.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Clock } from "lucide-react";
|
||||
import { BillAction } from "@/lib/types";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
interface ActionTimelineProps {
|
||||
actions: BillAction[];
|
||||
latestActionDate?: string;
|
||||
latestActionText?: string;
|
||||
}
|
||||
|
||||
export function ActionTimeline({ actions, latestActionDate, latestActionText }: ActionTimelineProps) {
|
||||
const hasActions = actions && actions.length > 0;
|
||||
const hasFallback = !hasActions && latestActionText;
|
||||
|
||||
if (!hasActions && !hasFallback) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="font-semibold mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Action History
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground italic">No actions recorded yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="font-semibold mb-4 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Action History
|
||||
{hasActions && (
|
||||
<span className="text-xs text-muted-foreground font-normal">({actions.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-2 top-0 bottom-0 w-px bg-border" />
|
||||
<ul className="space-y-4 pl-7">
|
||||
{hasActions ? (
|
||||
actions.map((action) => (
|
||||
<li key={action.id} className="relative">
|
||||
<div className="absolute -left-5 top-1.5 w-2 h-2 rounded-full bg-primary/60 border-2 border-background" />
|
||||
<div className="text-xs text-muted-foreground mb-0.5">
|
||||
{formatDate(action.action_date)}
|
||||
{action.chamber && ` · ${action.chamber}`}
|
||||
</div>
|
||||
<p className="text-sm leading-snug">{action.action_text}</p>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="relative">
|
||||
<div className="absolute -left-5 top-1.5 w-2 h-2 rounded-full bg-muted-foreground/40 border-2 border-background" />
|
||||
<div className="text-xs text-muted-foreground mb-0.5">
|
||||
{formatDate(latestActionDate)}
|
||||
<span className="ml-1.5 italic">· latest known action</span>
|
||||
</div>
|
||||
<p className="text-sm leading-snug">{latestActionText}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 italic">
|
||||
Full history loads in the background — refresh to see all actions.
|
||||
</p>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/components/bills/BriefPanel.tsx
Normal file
141
frontend/components/bills/BriefPanel.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, RefreshCw, Share2 } from "lucide-react";
|
||||
import { BriefSchema } from "@/lib/types";
|
||||
import { AIBriefCard } from "@/components/bills/AIBriefCard";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
interface BriefPanelProps {
|
||||
briefs?: BriefSchema[] | null;
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
amendment: "AMENDMENT",
|
||||
full: "FULL",
|
||||
};
|
||||
|
||||
function typeBadge(briefType?: string) {
|
||||
const label = TYPE_LABEL[briefType ?? ""] ?? (briefType?.toUpperCase() ?? "BRIEF");
|
||||
const isAmendment = briefType === "amendment";
|
||||
return (
|
||||
<span
|
||||
className={`text-xs font-mono px-1.5 py-0.5 rounded ${
|
||||
isAmendment
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BriefPanel({ briefs }: BriefPanelProps) {
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function copyShareLink(brief: BriefSchema) {
|
||||
if (!brief.share_token) return;
|
||||
navigator.clipboard.writeText(`${window.location.origin}/share/brief/${brief.share_token}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
if (!briefs || briefs.length === 0) {
|
||||
return <AIBriefCard brief={null} />;
|
||||
}
|
||||
|
||||
const latest = briefs[0];
|
||||
const history = briefs.slice(1);
|
||||
const isAmendment = latest.brief_type === "amendment";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* "What Changed" badge row */}
|
||||
{isAmendment && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<RefreshCw className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="text-sm font-semibold text-amber-600 dark:text-amber-400">
|
||||
What Changed
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{formatDate(latest.created_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share button row */}
|
||||
{latest.share_token && (
|
||||
<div className="flex justify-end px-1">
|
||||
<button
|
||||
onClick={() => copyShareLink(latest)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy shareable link to this brief"
|
||||
>
|
||||
{copied ? (
|
||||
<><Check className="w-3.5 h-3.5 text-green-500" /> Link copied!</>
|
||||
) : (
|
||||
<><Share2 className="w-3.5 h-3.5" /> Share brief</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Latest brief */}
|
||||
<AIBriefCard brief={latest} />
|
||||
|
||||
{/* Version history (only when there are older briefs) */}
|
||||
{history.length > 0 && (
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors text-left"
|
||||
>
|
||||
{historyOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
Version History ({history.length} {history.length === 1 ? "version" : "versions"})
|
||||
</button>
|
||||
|
||||
{historyOpen && (
|
||||
<div className="border-t border-border divide-y divide-border">
|
||||
{history.map((brief) => (
|
||||
<div key={brief.id}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedId((id) => (id === brief.id ? null : brief.id))
|
||||
}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0">
|
||||
{formatDate(brief.created_at)}
|
||||
</span>
|
||||
{typeBadge(brief.brief_type)}
|
||||
<span className="text-xs text-muted-foreground truncate flex-1">
|
||||
{brief.summary?.slice(0, 120) ?? "No summary"}
|
||||
{(brief.summary?.length ?? 0) > 120 ? "…" : ""}
|
||||
</span>
|
||||
{expandedId === brief.id ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedId === brief.id && (
|
||||
<div className="px-4 pb-4">
|
||||
<AIBriefCard brief={brief} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
frontend/components/bills/CollectionPicker.tsx
Normal file
143
frontend/components/bills/CollectionPicker.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Bookmark, Check } from "lucide-react";
|
||||
import { collectionsAPI } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import type { Collection } from "@/lib/types";
|
||||
|
||||
interface CollectionPickerProps {
|
||||
billId: string;
|
||||
}
|
||||
|
||||
export function CollectionPicker({ billId }: CollectionPickerProps) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
}, [open]);
|
||||
|
||||
const { data: collections } = useQuery({
|
||||
queryKey: ["collections"],
|
||||
queryFn: collectionsAPI.list,
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (id: number) => collectionsAPI.addBill(id, billId),
|
||||
onSuccess: (_, id) => {
|
||||
qc.invalidateQueries({ queryKey: ["collections"] });
|
||||
qc.invalidateQueries({ queryKey: ["collection", id] });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: number) => collectionsAPI.removeBill(id, billId),
|
||||
onSuccess: (_, id) => {
|
||||
qc.invalidateQueries({ queryKey: ["collections"] });
|
||||
qc.invalidateQueries({ queryKey: ["collection", id] });
|
||||
},
|
||||
});
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
// Determine which collections contain this bill
|
||||
// We check each collection's bill_count proxy by re-fetching detail... but since the list
|
||||
// endpoint doesn't return bill_ids, we use a lightweight approach: track via optimistic state.
|
||||
// The collection detail page has the bill list; for the picker we just check each collection.
|
||||
// To avoid N+1, we'll use a separate query to get the user's collection memberships for this bill.
|
||||
// For simplicity, we use the collections list and compare via a bill-membership query.
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Add to collection"
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
open
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-56 bg-card border border-border rounded-lg shadow-lg overflow-hidden">
|
||||
{!collections || collections.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||
No collections yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{collections.map((c: Collection) => (
|
||||
<CollectionPickerRow
|
||||
key={c.id}
|
||||
collection={c}
|
||||
billId={billId}
|
||||
onAdd={() => addMutation.mutate(c.id)}
|
||||
onRemove={() => removeMutation.mutate(c.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<Link
|
||||
href="/collections"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
New collection →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionPickerRow({
|
||||
collection,
|
||||
billId,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: {
|
||||
collection: Collection;
|
||||
billId: string;
|
||||
onAdd: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
// Fetch detail to know if this bill is in the collection
|
||||
const { data: detail } = useQuery({
|
||||
queryKey: ["collection", collection.id],
|
||||
queryFn: () => collectionsAPI.get(collection.id),
|
||||
});
|
||||
|
||||
const inCollection = detail?.bills.some((b) => b.bill_id === billId) ?? false;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={inCollection ? onRemove : onAdd}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<span className="w-4 h-4 shrink-0 flex items-center justify-center">
|
||||
{inCollection && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</span>
|
||||
<span className="truncate flex-1">{collection.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
434
frontend/components/bills/DraftLetterPanel.tsx
Normal file
434
frontend/components/bills/DraftLetterPanel.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronRight, Copy, Check, ExternalLink, Loader2, Phone, PenLine } from "lucide-react";
|
||||
import type { BriefSchema, CitedPoint, Member } from "@/lib/types";
|
||||
import { billsAPI, membersAPI } from "@/lib/api";
|
||||
import { useIsFollowing } from "@/lib/hooks/useFollows";
|
||||
|
||||
interface DraftLetterPanelProps {
|
||||
billId: string;
|
||||
brief: BriefSchema;
|
||||
chamber?: string;
|
||||
}
|
||||
|
||||
type Stance = "yes" | "no" | null;
|
||||
type Tone = "short" | "polite" | "firm";
|
||||
|
||||
function pointText(p: string | CitedPoint): string {
|
||||
return typeof p === "string" ? p : p.text;
|
||||
}
|
||||
|
||||
function pointKey(p: string | CitedPoint, i: number): string {
|
||||
return `${i}-${typeof p === "string" ? p.slice(0, 40) : p.text.slice(0, 40)}`;
|
||||
}
|
||||
|
||||
function chamberToRecipient(chamber?: string): "house" | "senate" {
|
||||
return chamber?.toLowerCase() === "senate" ? "senate" : "house";
|
||||
}
|
||||
|
||||
function formatRepName(member: Member): string {
|
||||
// DB stores name as "Last, First" — convert to "First Last" for the letter
|
||||
if (member.name.includes(", ")) {
|
||||
const [last, first] = member.name.split(", ");
|
||||
return `${first} ${last}`;
|
||||
}
|
||||
return member.name;
|
||||
}
|
||||
|
||||
export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const existing = useIsFollowing("bill", billId);
|
||||
const [stance, setStance] = useState<Stance>(null);
|
||||
const prevModeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Keep stance in sync with follow mode changes (including unfollow → null)
|
||||
useEffect(() => {
|
||||
const newMode = existing?.follow_mode;
|
||||
if (newMode === prevModeRef.current) return;
|
||||
prevModeRef.current = newMode;
|
||||
if (newMode === "pocket_boost") setStance("yes");
|
||||
else if (newMode === "pocket_veto") setStance("no");
|
||||
else setStance(null);
|
||||
}, [existing?.follow_mode]);
|
||||
|
||||
const recipient = chamberToRecipient(chamber);
|
||||
const [tone, setTone] = useState<Tone>("polite");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [includeCitations, setIncludeCitations] = useState(true);
|
||||
const [zipCode, setZipCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Zip → rep lookup (debounced via React Query enabled flag)
|
||||
const zipTrimmed = zipCode.trim();
|
||||
const isValidZip = /^\d{5}$/.test(zipTrimmed);
|
||||
const { data: zipReps, isFetching: zipFetching } = useQuery({
|
||||
queryKey: ["members-by-zip", zipTrimmed],
|
||||
queryFn: () => membersAPI.byZip(zipTrimmed),
|
||||
enabled: isValidZip,
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Filter reps to match the bill's chamber
|
||||
const relevantReps = zipReps?.filter((m) =>
|
||||
recipient === "senate"
|
||||
? m.chamber === "Senate"
|
||||
: m.chamber === "House of Representatives"
|
||||
) ?? [];
|
||||
|
||||
// Use first matched rep's name for the letter salutation
|
||||
const repName = relevantReps.length > 0 ? formatRepName(relevantReps[0]) : undefined;
|
||||
|
||||
const keyPoints = brief.key_points ?? [];
|
||||
const risks = brief.risks ?? [];
|
||||
const allPoints = [
|
||||
...keyPoints.map((p, i) => ({ group: "key" as const, index: i, text: pointText(p), raw: p })),
|
||||
...risks.map((p, i) => ({ group: "risk" as const, index: keyPoints.length + i, text: pointText(p), raw: p })),
|
||||
];
|
||||
|
||||
function togglePoint(globalIndex: number) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(globalIndex)) {
|
||||
next.delete(globalIndex);
|
||||
} else if (next.size < 3) {
|
||||
next.add(globalIndex);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
if (selected.size === 0 || stance === null) return;
|
||||
|
||||
const selectedPoints = allPoints
|
||||
.filter((p) => selected.has(p.index))
|
||||
.map((p) => {
|
||||
if (includeCitations && typeof p.raw !== "string" && p.raw.citation) {
|
||||
return `${p.text} (${p.raw.citation})`;
|
||||
}
|
||||
return p.text;
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDraft(null);
|
||||
|
||||
try {
|
||||
const result = await billsAPI.generateDraft(billId, {
|
||||
stance,
|
||||
recipient,
|
||||
tone,
|
||||
selected_points: selectedPoints,
|
||||
include_citations: includeCitations,
|
||||
zip_code: zipCode.trim() || undefined,
|
||||
rep_name: repName,
|
||||
});
|
||||
setDraft(result.draft);
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"response" in err &&
|
||||
err.response &&
|
||||
typeof err.response === "object" &&
|
||||
"data" in err.response &&
|
||||
err.response.data &&
|
||||
typeof err.response.data === "object" &&
|
||||
"detail" in err.response.data
|
||||
? String((err.response.data as { detail: string }).detail)
|
||||
: "Failed to generate letter. Please try again.";
|
||||
setError(detail);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!draft) return;
|
||||
await navigator.clipboard.writeText(draft);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors text-left"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<PenLine className="w-3.5 h-3.5 shrink-0" />
|
||||
Draft a letter to your {recipient === "senate" ? "senator" : "representative"}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border px-4 py-4 space-y-4">
|
||||
{/* Stance + Tone */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Stance</p>
|
||||
<div className="flex rounded-md overflow-hidden border border-border text-xs">
|
||||
{(["yes", "no"] as ("yes" | "no")[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStance(s)}
|
||||
className={`px-3 py-1.5 font-medium transition-colors ${
|
||||
stance === s
|
||||
? s === "yes"
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-red-600 text-white"
|
||||
: "bg-background text-muted-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
{s === "yes" ? "Support (Vote YES)" : "Oppose (Vote NO)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{stance === null && (
|
||||
<p className="text-[10px] text-amber-500 mt-1">Select a position to continue</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Tone</p>
|
||||
<select
|
||||
value={tone}
|
||||
onChange={(e) => setTone(e.target.value as Tone)}
|
||||
className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground"
|
||||
>
|
||||
<option value="short">Short</option>
|
||||
<option value="polite">Polite</option>
|
||||
<option value="firm">Firm</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Point selector */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Select up to 3 points to include
|
||||
{selected.size > 0 && (
|
||||
<span className="ml-1 text-muted-foreground">({selected.size}/3)</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{keyPoints.length > 0 && (
|
||||
<>
|
||||
<p className="px-3 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40">
|
||||
Key Points
|
||||
</p>
|
||||
{keyPoints.map((p, i) => {
|
||||
const globalIndex = i;
|
||||
const isChecked = selected.has(globalIndex);
|
||||
const isDisabled = !isChecked && selected.size >= 3;
|
||||
return (
|
||||
<label
|
||||
key={pointKey(p, i)}
|
||||
className={`flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors ${
|
||||
isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-accent/40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
disabled={isDisabled}
|
||||
onChange={() => togglePoint(globalIndex)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-foreground leading-snug">{pointText(p)}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{risks.length > 0 && (
|
||||
<>
|
||||
<p className="px-3 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40">
|
||||
Concerns
|
||||
</p>
|
||||
{risks.map((p, i) => {
|
||||
const globalIndex = keyPoints.length + i;
|
||||
const isChecked = selected.has(globalIndex);
|
||||
const isDisabled = !isChecked && selected.size >= 3;
|
||||
return (
|
||||
<label
|
||||
key={pointKey(p, keyPoints.length + i)}
|
||||
className={`flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors ${
|
||||
isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-accent/40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
disabled={isDisabled}
|
||||
onChange={() => togglePoint(globalIndex)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-foreground leading-snug">{pointText(p)}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options row */}
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={zipCode}
|
||||
onChange={(e) => setZipCode(e.target.value)}
|
||||
placeholder="ZIP code"
|
||||
maxLength={10}
|
||||
className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground w-28 placeholder:text-muted-foreground"
|
||||
/>
|
||||
{zipFetching && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">optional · not stored</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-muted-foreground mt-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeCitations}
|
||||
onChange={(e) => setIncludeCitations(e.target.checked)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
Include citations
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Rep lookup results */}
|
||||
{isValidZip && !zipFetching && relevantReps.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Your {recipient === "senate" ? "senators" : "representative"}
|
||||
</p>
|
||||
{relevantReps.map((rep) => (
|
||||
<div
|
||||
key={rep.bioguide_id}
|
||||
className="flex items-center gap-3 bg-muted/40 border border-border rounded-md px-3 py-2"
|
||||
>
|
||||
{rep.photo_url && (
|
||||
<img
|
||||
src={rep.photo_url}
|
||||
alt={rep.name}
|
||||
className="w-8 h-8 rounded-full object-cover shrink-0 border border-border"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium">{formatRepName(rep)}</p>
|
||||
{rep.party && (
|
||||
<p className="text-[10px] text-muted-foreground">{rep.party} · {rep.state}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{rep.phone && (
|
||||
<a
|
||||
href={`tel:${rep.phone.replace(/\D/g, "")}`}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Office phone"
|
||||
>
|
||||
<Phone className="w-3 h-3" />
|
||||
{rep.phone}
|
||||
</a>
|
||||
)}
|
||||
{rep.official_url && (
|
||||
<a
|
||||
href={rep.official_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:underline"
|
||||
title="Contact form"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{repName && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Letter will be addressed to{" "}
|
||||
{recipient === "senate" ? "Senator" : "Representative"} {repName}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isValidZip && !zipFetching && relevantReps.length === 0 && zipReps !== undefined && (
|
||||
<p className="text-[10px] text-amber-500">
|
||||
Could not find your {recipient === "senate" ? "senators" : "representative"} for that ZIP.
|
||||
The letter will use a generic salutation.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Generate button */}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || selected.size === 0 || stance === null}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-xs font-medium rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{loading ? "Generating…" : "Generate letter"}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Draft output */}
|
||||
{draft && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground italic">Edit before sending</p>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
<span className="text-green-500">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
value={draft}
|
||||
rows={10}
|
||||
className="w-full text-xs bg-muted/30 border border-border rounded-md px-3 py-2 text-foreground resize-y font-sans leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-[10px] text-muted-foreground border-t border-border pt-3">
|
||||
Based only on the bill's cited text · We don't store your location
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/components/bills/NewsPanel.tsx
Normal file
53
frontend/components/bills/NewsPanel.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ExternalLink, Newspaper } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
interface ArticleLike {
|
||||
id: number;
|
||||
source?: string;
|
||||
headline?: string;
|
||||
url?: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
interface NewsPanelProps {
|
||||
articles?: ArticleLike[];
|
||||
}
|
||||
|
||||
export function NewsPanel({ articles }: NewsPanelProps) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2 mb-3">
|
||||
<Newspaper className="w-4 h-4" />
|
||||
Related News
|
||||
{articles && articles.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground font-normal">({articles.length})</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{!articles || articles.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No news articles found yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{articles.slice(0, 8).map((article) => (
|
||||
<li key={article.id} className="group">
|
||||
<a
|
||||
href={article.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block hover:text-primary transition-colors"
|
||||
>
|
||||
<p className="text-xs font-medium line-clamp-2 leading-snug group-hover:underline">
|
||||
{article.headline}
|
||||
<ExternalLink className="w-3 h-3 inline ml-1 opacity-50" />
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{article.source} · {formatDate(article.published_at)}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
frontend/components/bills/NotesPanel.tsx
Normal file
130
frontend/components/bills/NotesPanel.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Pin, PinOff, Trash2, Save } from "lucide-react";
|
||||
import { notesAPI } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
interface NotesPanelProps {
|
||||
billId: string;
|
||||
}
|
||||
|
||||
export function NotesPanel({ billId }: NotesPanelProps) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const qc = useQueryClient();
|
||||
const queryKey = ["note", billId];
|
||||
|
||||
const { data: note, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => notesAPI.get(billId),
|
||||
enabled: !!token,
|
||||
retry: false, // 404 = no note; don't retry
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
const [content, setContent] = useState("");
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Sync form from loaded note
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
setContent(note.content);
|
||||
setPinned(note.pinned);
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [content]);
|
||||
|
||||
const upsert = useMutation({
|
||||
mutationFn: () => notesAPI.upsert(billId, content, pinned),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(queryKey, updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => notesAPI.delete(billId),
|
||||
onSuccess: () => {
|
||||
qc.removeQueries({ queryKey });
|
||||
setContent("");
|
||||
setPinned(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (!token) return (
|
||||
<div className="bg-card border border-border rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">Sign in to add private notes.</p>
|
||||
</div>
|
||||
);
|
||||
if (isLoading) return null;
|
||||
|
||||
const hasNote = !!note;
|
||||
const isDirty = hasNote
|
||||
? content !== note.content || pinned !== note.pinned
|
||||
: content.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Add a private note about this bill…"
|
||||
rows={3}
|
||||
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary resize-none overflow-hidden"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Left: pin toggle + delete */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setPinned((v) => !v)}
|
||||
title={pinned ? "Unpin note" : "Pin above tabs"}
|
||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
|
||||
pinned
|
||||
? "border-primary text-primary bg-primary/10"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{pinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
|
||||
{pinned ? "Pinned" : "Pin"}
|
||||
</button>
|
||||
|
||||
{hasNote && (
|
||||
<button
|
||||
onClick={() => remove.mutate()}
|
||||
disabled={remove.isPending}
|
||||
title="Delete note"
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: save */}
|
||||
<button
|
||||
onClick={() => upsert.mutate()}
|
||||
disabled={!content.trim() || upsert.isPending || (!isDirty && !saved)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{saved ? "Saved!" : upsert.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">Private — only visible to you.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
frontend/components/bills/TrendChart.tsx
Normal file
134
frontend/components/bills/TrendChart.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { TrendingUp, Newspaper, Radio } from "lucide-react";
|
||||
import {
|
||||
ComposedChart,
|
||||
Line,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { TrendScore, MemberTrendScore } from "@/lib/types";
|
||||
|
||||
type AnyTrendScore = TrendScore | MemberTrendScore;
|
||||
|
||||
interface TrendChartProps {
|
||||
data?: AnyTrendScore[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function ScoreBadge({ label, value, icon }: { label: string; value: number | string; icon: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<div className="text-muted-foreground">{icon}</div>
|
||||
<span className="text-xs font-semibold tabular-nums">{value}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendChart({ data, title = "Public Interest" }: TrendChartProps) {
|
||||
const chartData = data?.map((d) => ({
|
||||
date: new Date(d.score_date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
||||
score: Math.round(d.composite_score),
|
||||
newsapi: d.newsapi_count,
|
||||
gnews: d.gnews_count,
|
||||
gtrends: Math.round(d.gtrends_score),
|
||||
})) ?? [];
|
||||
|
||||
const latest = data?.[data.length - 1];
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
{title}
|
||||
</h3>
|
||||
{latest !== undefined && (
|
||||
<span className="text-2xl font-bold tabular-nums">{Math.round(latest.composite_score)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signal breakdown badges */}
|
||||
{latest && (
|
||||
<div className="flex justify-around border border-border rounded-md p-2 bg-muted/30">
|
||||
<ScoreBadge
|
||||
label="NewsAPI"
|
||||
value={latest.newsapi_count}
|
||||
icon={<Newspaper className="w-3 h-3" />}
|
||||
/>
|
||||
<div className="w-px bg-border" />
|
||||
<ScoreBadge
|
||||
label="Google News"
|
||||
value={latest.gnews_count}
|
||||
icon={<Radio className="w-3 h-3" />}
|
||||
/>
|
||||
<div className="w-px bg-border" />
|
||||
<ScoreBadge
|
||||
label="Trends"
|
||||
value={`${Math.round(latest.gtrends_score)}/100`}
|
||||
icon={<TrendingUp className="w-3 h-3" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chartData.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic text-center py-8">
|
||||
Interest data not yet available. Check back after the nightly scoring run.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<ComposedChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
formatter={(value: number, name: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
score: "Composite",
|
||||
newsapi: "NewsAPI articles",
|
||||
gnews: "Google News articles",
|
||||
gtrends: "Google Trends",
|
||||
};
|
||||
return [value, labels[name] ?? name];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="gnews" fill="hsl(var(--muted-foreground))" opacity={0.3} name="gnews" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="newsapi" fill="hsl(var(--primary))" opacity={0.3} name="newsapi" radius={[2, 2, 0, 0]} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="score"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Composite 0–100 · NewsAPI articles (max 40 pts) + Google News volume (max 30 pts) + Google Trends score (max 30 pts)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
frontend/components/bills/VotePanel.tsx
Normal file
226
frontend/components/bills/VotePanel.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListChecks, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { billsAPI, followsAPI } from "@/lib/api";
|
||||
import { cn, formatDate, partyBadgeColor } from "@/lib/utils";
|
||||
import type { BillVote, MemberVotePosition } from "@/lib/types";
|
||||
|
||||
interface VotePanelProps {
|
||||
billId: string;
|
||||
alwaysRender?: boolean;
|
||||
}
|
||||
|
||||
export function VotePanel({ billId, alwaysRender = false }: VotePanelProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const { data: votes, isLoading } = useQuery({
|
||||
queryKey: ["votes", billId],
|
||||
queryFn: () => billsAPI.getVotes(billId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: follows } = useQuery({
|
||||
queryKey: ["follows"],
|
||||
queryFn: () => followsAPI.list(),
|
||||
retry: false,
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
const followedMemberIds = new Set(
|
||||
(follows || [])
|
||||
.filter((f) => f.follow_type === "member")
|
||||
.map((f) => f.follow_value)
|
||||
);
|
||||
|
||||
if (isLoading || !votes || votes.length === 0) {
|
||||
if (!alwaysRender) return null;
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isLoading ? "Checking for roll-call votes…" : "No roll-call votes have been recorded for this bill."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
Roll-Call Votes{" "}
|
||||
<span className="text-muted-foreground font-normal">({votes.length})</span>
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="divide-y divide-border">
|
||||
{votes.map((vote) => (
|
||||
<VoteRow key={vote.id} vote={vote} followedMemberIds={followedMemberIds} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VoteRow({
|
||||
vote,
|
||||
followedMemberIds,
|
||||
}: {
|
||||
vote: BillVote;
|
||||
followedMemberIds: Set<string>;
|
||||
}) {
|
||||
const [showPositions, setShowPositions] = useState(false);
|
||||
|
||||
const total = (vote.yeas ?? 0) + (vote.nays ?? 0) + (vote.not_voting ?? 0);
|
||||
const yeaPct = total > 0 ? ((vote.yeas ?? 0) / total) * 100 : 0;
|
||||
const nayPct = total > 0 ? ((vote.nays ?? 0) / total) * 100 : 0;
|
||||
|
||||
const resultLower = (vote.result ?? "").toLowerCase();
|
||||
const passed =
|
||||
resultLower.includes("pass") ||
|
||||
resultLower.includes("agreed") ||
|
||||
resultLower.includes("adopted") ||
|
||||
resultLower.includes("enacted");
|
||||
|
||||
const followedPositions: MemberVotePosition[] = vote.positions.filter(
|
||||
(p) => p.bioguide_id && followedMemberIds.has(p.bioguide_id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{vote.result && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded font-medium shrink-0",
|
||||
passed
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
)}
|
||||
>
|
||||
{vote.result}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{vote.chamber} Roll #{vote.roll_number}
|
||||
{vote.vote_date && ` · ${formatDate(vote.vote_date)}`}
|
||||
</span>
|
||||
</div>
|
||||
{vote.question && (
|
||||
<p className="text-sm font-medium">{vote.question}</p>
|
||||
)}
|
||||
</div>
|
||||
{vote.source_url && (
|
||||
<a
|
||||
href={vote.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
Source ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Yea / Nay bar */}
|
||||
{total > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-2 rounded overflow-hidden bg-muted gap-0.5">
|
||||
<div
|
||||
className="bg-emerald-500 transition-all"
|
||||
style={{ width: `${yeaPct}%` }}
|
||||
title={`Yea: ${vote.yeas}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-500 transition-all"
|
||||
style={{ width: `${nayPct}%` }}
|
||||
title={`Nay: ${vote.nays}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
{vote.yeas ?? "—"} Yea
|
||||
</span>
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">
|
||||
{vote.nays ?? "—"} Nay
|
||||
</span>
|
||||
{(vote.not_voting ?? 0) > 0 && (
|
||||
<span className="text-muted-foreground">{vote.not_voting} Not Voting</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Followed member positions */}
|
||||
{followedPositions.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowPositions((e) => !e)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{showPositions ? "Hide" : "Show"} {followedPositions.length} followed member
|
||||
{followedPositions.length !== 1 ? "s'" : "'"} vote
|
||||
{followedPositions.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showPositions && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{followedPositions.map((p, i) => (
|
||||
<div key={p.bioguide_id ?? i} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
p.position === "Yea"
|
||||
? "bg-emerald-500"
|
||||
: p.position === "Nay"
|
||||
? "bg-red-500"
|
||||
: "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{vote.chamber === "Senate" ? "Sen." : "Rep."}
|
||||
</span>
|
||||
<span className="font-medium">{p.member_name}</span>
|
||||
{p.party && (
|
||||
<span className={cn("px-1 py-0.5 rounded font-medium shrink-0", partyBadgeColor(p.party))}>
|
||||
{p.party}
|
||||
</span>
|
||||
)}
|
||||
{p.state && <span className="text-muted-foreground">{p.state}</span>}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto font-medium shrink-0",
|
||||
p.position === "Yea"
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: p.position === "Nay"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{p.position}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
frontend/components/shared/AuthGuard.tsx
Normal file
72
frontend/components/shared/AuthGuard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { MobileHeader } from "./MobileHeader";
|
||||
|
||||
const NO_SHELL_PATHS = ["/login", "/register", "/share"];
|
||||
const AUTH_REQUIRED = ["/following", "/notifications", "/collections"];
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
// Zustand persist hydrates asynchronously — wait for it before rendering
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
|
||||
if (!token && needsAuth) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [hydrated, token, pathname, router]);
|
||||
|
||||
if (!hydrated) return null;
|
||||
|
||||
// Login/register/share pages render without the app shell
|
||||
if (NO_SHELL_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Auth-required pages: blank while redirecting
|
||||
const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
|
||||
if (!token && needsAuth) return null;
|
||||
|
||||
// Authenticated or guest browsing: render the full app shell
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Desktop sidebar — hidden on mobile */}
|
||||
<div className="hidden md:flex">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Mobile slide-in drawer */}
|
||||
{drawerOpen && (
|
||||
<div className="fixed inset-0 z-50 md:hidden">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setDrawerOpen(false)} />
|
||||
<div className="absolute left-0 top-0 bottom-0">
|
||||
<Sidebar onClose={() => setDrawerOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content column */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<MobileHeader onMenuClick={() => setDrawerOpen(true)} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="container mx-auto px-4 md:px-6 py-6 max-w-7xl">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
frontend/components/shared/AuthModal.tsx
Normal file
39
frontend/components/shared/AuthModal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface AuthModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AuthModal({ open, onClose }: AuthModalProps) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onClose}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-sm bg-card border border-border rounded-lg shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
|
||||
<Dialog.Title className="text-base font-semibold">
|
||||
Sign in to follow bills
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-2 text-sm text-muted-foreground">
|
||||
Create a free account to follow bills, set Pocket Veto or Pocket Boost modes, and receive alerts.
|
||||
</Dialog.Description>
|
||||
<div className="flex gap-3 mt-4">
|
||||
<Link href="/register" onClick={onClose} className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
Create account
|
||||
</Link>
|
||||
<Link href="/login" onClick={onClose} className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-md border border-border text-foreground hover:bg-accent transition-colors">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
<Dialog.Close className="absolute right-4 top-4 p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</Dialog.Close>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
103
frontend/components/shared/BillCard.tsx
Normal file
103
frontend/components/shared/BillCard.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import Link from "next/link";
|
||||
import { TrendingUp, Calendar, User, FileText, FileClock, FileX, Tag } from "lucide-react";
|
||||
import { Bill } from "@/lib/types";
|
||||
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils";
|
||||
import { FollowButton } from "./FollowButton";
|
||||
import { TOPIC_LABEL, TOPIC_TAGS } from "@/lib/topics";
|
||||
|
||||
interface BillCardProps {
|
||||
bill: Bill;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function BillCard({ bill, compact = false }: BillCardProps) {
|
||||
const label = billLabel(bill.bill_type, bill.bill_number);
|
||||
const score = bill.latest_trend?.composite_score;
|
||||
const tags = (bill.latest_brief?.topic_tags || []).filter((t) => TOPIC_TAGS.has(t)).slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4 hover:border-primary/30 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<span className="text-xs font-mono font-semibold text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{label}
|
||||
</span>
|
||||
{bill.chamber && (
|
||||
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", chamberBadgeColor(bill.chamber))}>
|
||||
{bill.chamber}
|
||||
</span>
|
||||
)}
|
||||
{tags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/bills?topic=${tag}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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>
|
||||
|
||||
<Link href={`/bills/${bill.bill_id}`}>
|
||||
<h3 className="text-sm font-medium leading-snug hover:text-primary transition-colors line-clamp-2">
|
||||
{bill.short_title || bill.title || "Untitled Bill"}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{!compact && bill.sponsor && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-xs text-muted-foreground">
|
||||
<User className="w-3 h-3" />
|
||||
<Link href={`/members/${bill.sponsor.bioguide_id}`} className="hover:text-foreground transition-colors">
|
||||
{bill.sponsor.name}
|
||||
</Link>
|
||||
{bill.sponsor.party && (
|
||||
<span className={cn("px-1 py-0.5 rounded text-xs font-medium", partyBadgeColor(bill.sponsor.party))}>
|
||||
{bill.sponsor.party}
|
||||
</span>
|
||||
)}
|
||||
{bill.sponsor.state && (
|
||||
<span>{bill.sponsor.state}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<FollowButton type="bill" value={bill.bill_id} supportsModes />
|
||||
{score !== undefined && score > 0 && (
|
||||
<div className={cn("flex items-center gap-1 text-xs font-medium", trendColor(score))}>
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{Math.round(score)}
|
||||
</div>
|
||||
)}
|
||||
{bill.latest_brief ? (
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400" title="Analysis available">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span>Brief</span>
|
||||
</div>
|
||||
) : bill.has_document ? (
|
||||
<div className="flex items-center gap-1 text-xs text-amber-500" title="Text retrieved, analysis pending">
|
||||
<FileClock className="w-3 h-3" />
|
||||
<span>Pending</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground/50" title="No bill text published">
|
||||
<FileX className="w-3 h-3" />
|
||||
<span>No text</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!compact && bill.latest_action_text && (
|
||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2 border-t border-border pt-2">
|
||||
<Calendar className="w-3 h-3 inline mr-1" />
|
||||
{formatDate(bill.latest_action_date)} — {bill.latest_action_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
frontend/components/shared/FollowButton.tsx
Normal file
188
frontend/components/shared/FollowButton.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { Heart, Shield, Zap, ChevronDown } from "lucide-react";
|
||||
import { useAddFollow, useIsFollowing, useRemoveFollow, useUpdateFollowMode } from "@/lib/hooks/useFollows";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { AuthModal } from "./AuthModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MODES = {
|
||||
neutral: {
|
||||
label: "Following",
|
||||
icon: Heart,
|
||||
color: "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400",
|
||||
},
|
||||
pocket_veto: {
|
||||
label: "Pocket Veto",
|
||||
icon: Shield,
|
||||
color: "bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400",
|
||||
},
|
||||
pocket_boost: {
|
||||
label: "Pocket Boost",
|
||||
icon: Zap,
|
||||
color: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type FollowMode = keyof typeof MODES;
|
||||
|
||||
interface FollowButtonProps {
|
||||
type: "bill" | "member" | "topic";
|
||||
value: string;
|
||||
label?: string;
|
||||
supportsModes?: boolean;
|
||||
}
|
||||
|
||||
export function FollowButton({ type, value, label, supportsModes = false }: FollowButtonProps) {
|
||||
const existing = useIsFollowing(type, value);
|
||||
const add = useAddFollow();
|
||||
const remove = useRemoveFollow();
|
||||
const updateMode = useUpdateFollowMode();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function requireAuth(action: () => void) {
|
||||
if (!token) { setShowAuthModal(true); return; }
|
||||
action();
|
||||
}
|
||||
|
||||
const isFollowing = !!existing;
|
||||
const currentMode: FollowMode = (existing?.follow_mode as FollowMode) ?? "neutral";
|
||||
const isPending = add.isPending || remove.isPending || updateMode.isPending;
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
// Simple toggle for non-bill follows
|
||||
if (!supportsModes) {
|
||||
const handleClick = () => {
|
||||
requireAuth(() => {
|
||||
if (isFollowing && existing) {
|
||||
remove.mutate(existing.id);
|
||||
} else {
|
||||
add.mutate({ type, value });
|
||||
}
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
|
||||
isFollowing
|
||||
? "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Heart className={cn("w-3.5 h-3.5", isFollowing && "fill-current")} />
|
||||
{isFollowing ? "Unfollow" : label || "Follow"}
|
||||
</button>
|
||||
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Mode-aware follow button for bills
|
||||
if (!isFollowing) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => requireAuth(() => add.mutate({ type, value }))}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Heart className="w-3.5 h-3.5" />
|
||||
{label || "Follow"}
|
||||
</button>
|
||||
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { label: modeLabel, icon: ModeIcon, color } = MODES[currentMode];
|
||||
const otherModes = (Object.keys(MODES) as FollowMode[]).filter((m) => m !== currentMode);
|
||||
|
||||
const switchMode = (mode: FollowMode) => {
|
||||
requireAuth(() => {
|
||||
if (existing) updateMode.mutate({ id: existing.id, mode });
|
||||
setOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnfollow = () => {
|
||||
requireAuth(() => {
|
||||
if (existing) remove.mutate(existing.id);
|
||||
setOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const modeDescriptions: Record<FollowMode, string> = {
|
||||
neutral: "Alert me on all material changes",
|
||||
pocket_veto: "Alert me only if this bill advances toward passage",
|
||||
pocket_boost: "Alert me on all changes + remind me to contact my rep",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
|
||||
color
|
||||
)}
|
||||
>
|
||||
<ModeIcon className={cn("w-3.5 h-3.5", currentMode === "neutral" && "fill-current")} />
|
||||
{modeLabel}
|
||||
<ChevronDown className="w-3 h-3 ml-0.5 opacity-70" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 w-64 bg-card border border-border rounded-md shadow-lg z-50 py-1">
|
||||
{otherModes.map((mode) => {
|
||||
const { label: optLabel, icon: OptIcon } = MODES[mode];
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => switchMode(mode)}
|
||||
title={modeDescriptions[mode]}
|
||||
className="w-full text-left px-3 py-2 text-sm bg-card hover:bg-accent text-card-foreground transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 font-medium">
|
||||
<OptIcon className="w-3.5 h-3.5" />
|
||||
Switch to {optLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground pl-5">{modeDescriptions[mode]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="border-t border-border mt-1 pt-1">
|
||||
<button
|
||||
onClick={handleUnfollow}
|
||||
className="w-full text-left px-3 py-2 text-sm bg-card hover:bg-accent text-destructive transition-colors"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
frontend/components/shared/HelpTip.tsx
Normal file
46
frontend/components/shared/HelpTip.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HelpTipProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HelpTip({ content, className }: HelpTipProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-flex items-center", className)} ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setVisible(true)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
aria-label="Help"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{visible && (
|
||||
<div className="absolute left-5 top-0 z-50 w-64 bg-popover border border-border rounded-md shadow-lg p-3 text-xs text-muted-foreground leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
frontend/components/shared/MobileHeader.tsx
Normal file
16
frontend/components/shared/MobileHeader.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
import { Menu, Landmark } from "lucide-react";
|
||||
|
||||
export function MobileHeader({ onMenuClick }: { onMenuClick: () => void }) {
|
||||
return (
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 border-b border-border bg-card shrink-0">
|
||||
<button onClick={onMenuClick} className="p-2 rounded-md hover:bg-accent transition-colors" aria-label="Open menu">
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
<span className="font-semibold text-sm">PocketVeto</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
188
frontend/components/shared/Sidebar.tsx
Normal file
188
frontend/components/shared/Sidebar.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Bookmark,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
HelpCircle,
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Users,
|
||||
Tags,
|
||||
Heart,
|
||||
Bell,
|
||||
Settings,
|
||||
BarChart2,
|
||||
Landmark,
|
||||
LogOut,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard, adminOnly: false, requiresAuth: false },
|
||||
{ href: "/bills", label: "Bills", icon: FileText, adminOnly: false, requiresAuth: false },
|
||||
{ href: "/members", label: "Members", icon: Users, adminOnly: false, requiresAuth: false },
|
||||
{ href: "/topics", label: "Topics", icon: Tags, adminOnly: false, requiresAuth: false },
|
||||
{ href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true },
|
||||
{ href: "/alignment", label: "Alignment", icon: BarChart2, adminOnly: false, requiresAuth: true },
|
||||
{ href: "/collections", label: "Collections", icon: Bookmark, adminOnly: false, requiresAuth: true },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false, requiresAuth: true },
|
||||
{ href: "/how-it-works", label: "How it works", icon: HelpCircle, adminOnly: false, requiresAuth: false },
|
||||
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false },
|
||||
];
|
||||
|
||||
export function Sidebar({ onClose }: { onClose?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// Mobile drawer always shows full sidebar
|
||||
const isMobile = !!onClose;
|
||||
const isCollapsed = collapsed && !isMobile;
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("sidebar-collapsed");
|
||||
if (saved === "true") setCollapsed(true);
|
||||
}, []);
|
||||
|
||||
function toggleCollapsed() {
|
||||
setCollapsed((v) => {
|
||||
localStorage.setItem("sidebar-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
qc.clear();
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"shrink-0 border-r border-border bg-card flex flex-col transition-all duration-200",
|
||||
isCollapsed ? "w-14" : "w-56"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"h-14 border-b border-border flex items-center gap-2 px-4",
|
||||
isCollapsed && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
<Landmark className="w-5 h-5 text-primary shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="font-semibold text-sm flex-1">PocketVeto</span>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-accent transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 p-2 space-y-0.5">
|
||||
{NAV.filter(({ adminOnly, requiresAuth }) => {
|
||||
if (adminOnly && !user?.is_admin) return false;
|
||||
if (requiresAuth && !token) return false;
|
||||
return true;
|
||||
}).map(({ href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={onClose}
|
||||
title={isCollapsed ? label : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
isCollapsed && "justify-center px-0",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{!isCollapsed && label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={cn("p-3 border-t border-border space-y-2", isCollapsed && "p-2")}>
|
||||
{token ? (
|
||||
user && (
|
||||
<div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
|
||||
{!isCollapsed && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[120px]" title={user.email}>
|
||||
{user.email}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : !isCollapsed ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href="/register"
|
||||
onClick={onClose}
|
||||
className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
onClick={onClose}
|
||||
className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md border border-border text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
|
||||
{!isCollapsed && <span className="text-xs text-muted-foreground">Theme</span>}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle — desktop only */}
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
className="w-full flex items-center justify-center p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
19
frontend/components/shared/ThemeToggle.tsx
Normal file
19
frontend/components/shared/ThemeToggle.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<Sun className="w-4 h-4 hidden dark:block" />
|
||||
<Moon className="w-4 h-4 dark:hidden" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
71
frontend/components/shared/WelcomeBanner.tsx
Normal file
71
frontend/components/shared/WelcomeBanner.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, BookOpen, GitCompare, ShieldCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
const STORAGE_KEY = "pv_seen_welcome";
|
||||
|
||||
export function WelcomeBanner() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token && localStorage.getItem(STORAGE_KEY) !== "1") {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "1");
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="relative bg-card border border-border rounded-lg p-5 pr-10">
|
||||
<button
|
||||
onClick={dismiss}
|
||||
title="Dismiss"
|
||||
className="absolute top-3 right-3 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<h2 className="font-semibold text-base mb-3">Welcome to PocketVeto</h2>
|
||||
|
||||
<ul className="space-y-2 mb-4">
|
||||
<li className="flex items-start gap-2.5 text-sm text-muted-foreground">
|
||||
<BookOpen className="w-4 h-4 mt-0.5 shrink-0 text-primary" />
|
||||
Follow bills, members, or topics — get low-noise alerts when things actually move
|
||||
</li>
|
||||
<li className="flex items-start gap-2.5 text-sm text-muted-foreground">
|
||||
<GitCompare className="w-4 h-4 mt-0.5 shrink-0 text-primary" />
|
||||
See <em>what changed</em> in plain English when bills are amended
|
||||
</li>
|
||||
<li className="flex items-start gap-2.5 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5 shrink-0 text-primary" />
|
||||
Verify every AI claim with <strong>Back to Source</strong> citations from the bill text
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/bills"
|
||||
onClick={dismiss}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Browse Bills
|
||||
</Link>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
frontend/lib/api.ts
Normal file
322
frontend/lib/api.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import axios from "axios";
|
||||
import type {
|
||||
AlignmentData,
|
||||
Bill,
|
||||
BillAction,
|
||||
BillDetail,
|
||||
BillVote,
|
||||
BriefSchema,
|
||||
Collection,
|
||||
CollectionDetail,
|
||||
DashboardData,
|
||||
Follow,
|
||||
Member,
|
||||
MemberTrendScore,
|
||||
MemberNewsArticle,
|
||||
NewsArticle,
|
||||
NotificationEvent,
|
||||
NotificationSettings,
|
||||
NotificationSettingsUpdate,
|
||||
PaginatedResponse,
|
||||
SettingsData,
|
||||
TrendScore,
|
||||
} from "./types";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || "",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Attach JWT from localStorage on every request
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const stored = localStorage.getItem("pocketveto-auth");
|
||||
if (stored) {
|
||||
const { state } = JSON.parse(stored);
|
||||
if (state?.token) {
|
||||
config.headers.Authorization = `Bearer ${state.token}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
notification_prefs: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const authAPI = {
|
||||
register: (email: string, password: string) =>
|
||||
apiClient.post<TokenResponse>("/api/auth/register", { email, password }).then((r) => r.data),
|
||||
login: (email: string, password: string) =>
|
||||
apiClient.post<TokenResponse>("/api/auth/login", { email, password }).then((r) => r.data),
|
||||
me: () =>
|
||||
apiClient.get<AuthUser>("/api/auth/me").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Bills
|
||||
export const billsAPI = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
apiClient.get<PaginatedResponse<Bill>>("/api/bills", { params }).then((r) => r.data),
|
||||
get: (id: string) =>
|
||||
apiClient.get<BillDetail>(`/api/bills/${id}`).then((r) => r.data),
|
||||
getActions: (id: string) =>
|
||||
apiClient.get<BillAction[]>(`/api/bills/${id}/actions`).then((r) => r.data),
|
||||
getNews: (id: string) =>
|
||||
apiClient.get<NewsArticle[]>(`/api/bills/${id}/news`).then((r) => r.data),
|
||||
getTrend: (id: string, days?: number) =>
|
||||
apiClient.get<TrendScore[]>(`/api/bills/${id}/trend`, { params: { days } }).then((r) => r.data),
|
||||
getVotes: (id: string) =>
|
||||
apiClient.get<BillVote[]>(`/api/bills/${id}/votes`).then((r) => r.data),
|
||||
generateDraft: (id: string, body: {
|
||||
stance: string;
|
||||
recipient: string;
|
||||
tone: string;
|
||||
selected_points: string[];
|
||||
include_citations: boolean;
|
||||
zip_code?: string;
|
||||
rep_name?: string;
|
||||
}) =>
|
||||
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Collections
|
||||
export const collectionsAPI = {
|
||||
list: () =>
|
||||
apiClient.get<Collection[]>("/api/collections").then((r) => r.data),
|
||||
create: (name: string, is_public: boolean) =>
|
||||
apiClient.post<Collection>("/api/collections", { name, is_public }).then((r) => r.data),
|
||||
get: (id: number) =>
|
||||
apiClient.get<CollectionDetail>(`/api/collections/${id}`).then((r) => r.data),
|
||||
update: (id: number, data: { name?: string; is_public?: boolean }) =>
|
||||
apiClient.patch<Collection>(`/api/collections/${id}`, data).then((r) => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/api/collections/${id}`),
|
||||
addBill: (id: number, bill_id: string) =>
|
||||
apiClient.post(`/api/collections/${id}/bills/${bill_id}`).then((r) => r.data),
|
||||
removeBill: (id: number, bill_id: string) =>
|
||||
apiClient.delete(`/api/collections/${id}/bills/${bill_id}`),
|
||||
getByShareToken: (token: string) =>
|
||||
apiClient.get<CollectionDetail>(`/api/collections/share/${token}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Share (public)
|
||||
export const shareAPI = {
|
||||
getBrief: (token: string) =>
|
||||
apiClient.get<{ brief: BriefSchema; bill: Bill }>(`/api/share/brief/${token}`).then((r) => r.data),
|
||||
getCollection: (token: string) =>
|
||||
apiClient.get<CollectionDetail>(`/api/share/collection/${token}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Notes
|
||||
export const notesAPI = {
|
||||
get: (billId: string) =>
|
||||
apiClient.get<import("./types").BillNote>(`/api/notes/${billId}`).then((r) => r.data),
|
||||
upsert: (billId: string, content: string, pinned: boolean) =>
|
||||
apiClient.put<import("./types").BillNote>(`/api/notes/${billId}`, { content, pinned }).then((r) => r.data),
|
||||
delete: (billId: string) =>
|
||||
apiClient.delete(`/api/notes/${billId}`),
|
||||
};
|
||||
|
||||
// Members
|
||||
export const membersAPI = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
apiClient.get<PaginatedResponse<Member>>("/api/members", { params }).then((r) => r.data),
|
||||
get: (id: string) =>
|
||||
apiClient.get<Member>(`/api/members/${id}`).then((r) => r.data),
|
||||
byZip: (zip: string) =>
|
||||
apiClient.get<Member[]>(`/api/members/by-zip/${zip}`).then((r) => r.data),
|
||||
getBills: (id: string, params?: Record<string, unknown>) =>
|
||||
apiClient.get<PaginatedResponse<Bill>>(`/api/members/${id}/bills`, { params }).then((r) => r.data),
|
||||
getTrend: (id: string, days?: number) =>
|
||||
apiClient.get<MemberTrendScore[]>(`/api/members/${id}/trend`, { params: { days } }).then((r) => r.data),
|
||||
getNews: (id: string) =>
|
||||
apiClient.get<MemberNewsArticle[]>(`/api/members/${id}/news`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Follows
|
||||
export const followsAPI = {
|
||||
list: () =>
|
||||
apiClient.get<Follow[]>("/api/follows").then((r) => r.data),
|
||||
add: (follow_type: string, follow_value: string) =>
|
||||
apiClient.post<Follow>("/api/follows", { follow_type, follow_value }).then((r) => r.data),
|
||||
remove: (id: number) =>
|
||||
apiClient.delete(`/api/follows/${id}`),
|
||||
updateMode: (id: number, mode: string) =>
|
||||
apiClient.patch<Follow>(`/api/follows/${id}/mode`, { follow_mode: mode }).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Dashboard
|
||||
export const dashboardAPI = {
|
||||
get: () =>
|
||||
apiClient.get<DashboardData>("/api/dashboard").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Search
|
||||
export const searchAPI = {
|
||||
search: (q: string) =>
|
||||
apiClient.get<{ bills: Bill[]; members: Member[] }>("/api/search", { params: { q } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export interface LLMModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Settings
|
||||
export const settingsAPI = {
|
||||
get: () =>
|
||||
apiClient.get<SettingsData>("/api/settings").then((r) => r.data),
|
||||
update: (key: string, value: string) =>
|
||||
apiClient.put("/api/settings", { key, value }).then((r) => r.data),
|
||||
testLLM: () =>
|
||||
apiClient.post("/api/settings/test-llm").then((r) => r.data),
|
||||
listModels: (provider: string) =>
|
||||
apiClient.get<{ models: LLMModel[]; error?: string }>("/api/settings/llm-models", { params: { provider } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
follow_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiHealthResult {
|
||||
status: "ok" | "error" | "skipped";
|
||||
detail: string;
|
||||
latency_ms?: number;
|
||||
}
|
||||
|
||||
export interface ApiHealth {
|
||||
congress_gov: ApiHealthResult;
|
||||
govinfo: ApiHealthResult;
|
||||
newsapi: ApiHealthResult;
|
||||
google_news: ApiHealthResult;
|
||||
}
|
||||
|
||||
export interface AnalysisStats {
|
||||
total_bills: number;
|
||||
docs_fetched: number;
|
||||
briefs_generated: number;
|
||||
full_briefs: number;
|
||||
amendment_briefs: number;
|
||||
uncited_briefs: number;
|
||||
no_text_bills: number;
|
||||
pending_llm: number;
|
||||
bills_missing_sponsor: number;
|
||||
bills_missing_metadata: number;
|
||||
bills_missing_actions: number;
|
||||
unlabeled_briefs: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface NotificationTestResult {
|
||||
status: "ok" | "error";
|
||||
detail: string;
|
||||
event_count?: number;
|
||||
}
|
||||
|
||||
// Notifications
|
||||
export const notificationsAPI = {
|
||||
getSettings: () =>
|
||||
apiClient.get<NotificationSettings>("/api/notifications/settings").then((r) => r.data),
|
||||
updateSettings: (data: NotificationSettingsUpdate) =>
|
||||
apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
|
||||
resetRssToken: () =>
|
||||
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data),
|
||||
testNtfy: (data: {
|
||||
ntfy_topic_url: string;
|
||||
ntfy_auth_method: string;
|
||||
ntfy_token: string;
|
||||
ntfy_username: string;
|
||||
ntfy_password: string;
|
||||
}) =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
|
||||
testRss: () =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data),
|
||||
testEmail: () =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/email").then((r) => r.data),
|
||||
testFollowMode: (mode: string, event_type: string) =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
|
||||
getHistory: () =>
|
||||
apiClient.get<NotificationEvent[]>("/api/notifications/history").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Admin
|
||||
export const adminAPI = {
|
||||
// Stats
|
||||
getStats: () =>
|
||||
apiClient.get<AnalysisStats>("/api/admin/stats").then((r) => r.data),
|
||||
// Users
|
||||
listUsers: () =>
|
||||
apiClient.get<AdminUser[]>("/api/admin/users").then((r) => r.data),
|
||||
deleteUser: (id: number) =>
|
||||
apiClient.delete(`/api/admin/users/${id}`),
|
||||
toggleAdmin: (id: number) =>
|
||||
apiClient.patch<AdminUser>(`/api/admin/users/${id}/toggle-admin`).then((r) => r.data),
|
||||
// Tasks
|
||||
triggerPoll: () =>
|
||||
apiClient.post("/api/admin/trigger-poll").then((r) => r.data),
|
||||
triggerMemberSync: () =>
|
||||
apiClient.post("/api/admin/trigger-member-sync").then((r) => r.data),
|
||||
triggerTrendScores: () =>
|
||||
apiClient.post("/api/admin/trigger-trend-scores").then((r) => r.data),
|
||||
backfillSponsors: () =>
|
||||
apiClient.post("/api/admin/backfill-sponsors").then((r) => r.data),
|
||||
backfillCitations: () =>
|
||||
apiClient.post("/api/admin/backfill-citations").then((r) => r.data),
|
||||
triggerFetchActions: () =>
|
||||
apiClient.post("/api/admin/trigger-fetch-actions").then((r) => r.data),
|
||||
backfillAllActions: () =>
|
||||
apiClient.post("/api/admin/backfill-all-actions").then((r) => r.data),
|
||||
backfillMetadata: () =>
|
||||
apiClient.post("/api/admin/backfill-metadata").then((r) => r.data),
|
||||
backfillLabels: () =>
|
||||
apiClient.post("/api/admin/backfill-labels").then((r) => r.data),
|
||||
resumeAnalysis: () =>
|
||||
apiClient.post("/api/admin/resume-analysis").then((r) => r.data),
|
||||
triggerWeeklyDigest: () =>
|
||||
apiClient.post("/api/admin/trigger-weekly-digest").then((r) => r.data),
|
||||
getApiHealth: () =>
|
||||
apiClient.get<ApiHealth>("/api/admin/api-health").then((r) => r.data),
|
||||
getTaskStatus: (taskId: string) =>
|
||||
apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data),
|
||||
getNewsApiQuota: () =>
|
||||
apiClient.get<{ remaining: number; limit: number }>("/api/admin/newsapi-quota").then((r) => r.data),
|
||||
clearGnewsCache: () =>
|
||||
apiClient.post<{ cleared: number }>("/api/admin/clear-gnews-cache").then((r) => r.data),
|
||||
submitLlmBatch: () =>
|
||||
apiClient.post("/api/admin/submit-llm-batch").then((r) => r.data),
|
||||
getLlmBatchStatus: () =>
|
||||
apiClient.get<{ status: string; batch_id?: string; doc_count?: number; submitted_at?: string }>(
|
||||
"/api/admin/llm-batch-status"
|
||||
).then((r) => r.data),
|
||||
backfillCosponsors: () =>
|
||||
apiClient.post("/api/admin/backfill-cosponsors").then((r) => r.data),
|
||||
backfillCategories: () =>
|
||||
apiClient.post("/api/admin/backfill-categories").then((r) => r.data),
|
||||
calculateEffectiveness: () =>
|
||||
apiClient.post("/api/admin/calculate-effectiveness").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Alignment
|
||||
export const alignmentAPI = {
|
||||
get: () =>
|
||||
apiClient.get<AlignmentData>("/api/alignment").then((r) => r.data),
|
||||
};
|
||||
46
frontend/lib/hooks/useBills.ts
Normal file
46
frontend/lib/hooks/useBills.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { billsAPI } from "../api";
|
||||
|
||||
export function useBills(params?: Record<string, unknown>) {
|
||||
return useQuery({
|
||||
queryKey: ["bills", params],
|
||||
queryFn: () => billsAPI.list(params),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBill(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["bill", id],
|
||||
queryFn: () => billsAPI.get(id),
|
||||
staleTime: 2 * 60 * 1000,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBillActions(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["bill-actions", id],
|
||||
queryFn: () => billsAPI.getActions(id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBillNews(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["bill-news", id],
|
||||
queryFn: () => billsAPI.getNews(id),
|
||||
staleTime: 0, // Always fetch fresh — news arrives async after brief generation
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBillTrend(id: string, days = 30) {
|
||||
return useQuery({
|
||||
queryKey: ["bill-trend", id, days],
|
||||
queryFn: () => billsAPI.getTrend(id, days),
|
||||
staleTime: 60 * 60 * 1000,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
13
frontend/lib/hooks/useDashboard.ts
Normal file
13
frontend/lib/hooks/useDashboard.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { dashboardAPI } from "../api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
export function useDashboard() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", !!token],
|
||||
queryFn: () => dashboardAPI.get(),
|
||||
staleTime: 2 * 60 * 1000,
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
50
frontend/lib/hooks/useFollows.ts
Normal file
50
frontend/lib/hooks/useFollows.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { followsAPI } from "../api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
export function useFollows() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
return useQuery({
|
||||
queryKey: ["follows"],
|
||||
queryFn: () => followsAPI.list(),
|
||||
staleTime: 30 * 1000,
|
||||
enabled: !!token,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddFollow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ type, value }: { type: string; value: string }) =>
|
||||
followsAPI.add(type, value),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["follows"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveFollow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => followsAPI.remove(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["follows"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useIsFollowing(type: string, value: string) {
|
||||
const { data: follows = [] } = useFollows();
|
||||
return follows.find((f) => f.follow_type === type && f.follow_value === value);
|
||||
}
|
||||
|
||||
export function useUpdateFollowMode() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, mode }: { id: number; mode: string }) =>
|
||||
followsAPI.updateMode(id, mode),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
|
||||
});
|
||||
}
|
||||
46
frontend/lib/hooks/useMembers.ts
Normal file
46
frontend/lib/hooks/useMembers.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { membersAPI } from "../api";
|
||||
|
||||
export function useMembers(params?: Record<string, unknown>) {
|
||||
return useQuery({
|
||||
queryKey: ["members", params],
|
||||
queryFn: () => membersAPI.list(params),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMember(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["member", id],
|
||||
queryFn: () => membersAPI.get(id),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMemberBills(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["member-bills", id],
|
||||
queryFn: () => membersAPI.getBills(id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMemberTrend(id: string, days = 30) {
|
||||
return useQuery({
|
||||
queryKey: ["member-trend", id, days],
|
||||
queryFn: () => membersAPI.getTrend(id, days),
|
||||
staleTime: 60 * 60 * 1000,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMemberNews(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["member-news", id],
|
||||
queryFn: () => membersAPI.getNews(id),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
28
frontend/lib/topics.ts
Normal file
28
frontend/lib/topics.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const TOPICS = [
|
||||
{ tag: "healthcare", label: "Healthcare", desc: "Health policy, insurance, Medicare, Medicaid" },
|
||||
{ tag: "taxation", label: "Taxation", desc: "Tax law, IRS, fiscal policy" },
|
||||
{ tag: "defense", label: "Defense", desc: "Military, NDAA, national security" },
|
||||
{ tag: "education", label: "Education", desc: "Schools, student loans, higher education" },
|
||||
{ tag: "immigration", label: "Immigration", desc: "Border, visas, asylum, citizenship" },
|
||||
{ tag: "environment", label: "Environment", desc: "Climate, EPA, conservation, energy" },
|
||||
{ tag: "housing", label: "Housing", desc: "Affordable housing, mortgages, HUD" },
|
||||
{ tag: "infrastructure", label: "Infrastructure", desc: "Roads, bridges, broadband, transit" },
|
||||
{ tag: "technology", label: "Technology", desc: "AI, cybersecurity, telecom, internet" },
|
||||
{ tag: "agriculture", label: "Agriculture", desc: "Farm bill, USDA, food policy" },
|
||||
{ tag: "judiciary", label: "Judiciary", desc: "Courts, criminal justice, civil rights" },
|
||||
{ tag: "foreign-policy", label: "Foreign Policy", desc: "Diplomacy, foreign aid, sanctions" },
|
||||
{ tag: "veterans", label: "Veterans", desc: "VA, veteran benefits, military families" },
|
||||
{ tag: "social-security", label: "Social Security", desc: "SS, Medicare, retirement benefits" },
|
||||
{ tag: "trade", label: "Trade", desc: "Tariffs, trade agreements, WTO" },
|
||||
{ tag: "budget", label: "Budget", desc: "Appropriations, debt ceiling, spending" },
|
||||
{ tag: "energy", label: "Energy", desc: "Oil, gas, renewables, nuclear" },
|
||||
{ tag: "banking", label: "Banking", desc: "Financial regulation, Fed, CFPB" },
|
||||
{ tag: "transportation", label: "Transportation", desc: "FAA, DOT, aviation, rail" },
|
||||
{ tag: "labor", label: "Labor", desc: "Minimum wage, unions, OSHA, employment" },
|
||||
];
|
||||
|
||||
export const TOPIC_TAGS = new Set(TOPICS.map((t) => t.tag));
|
||||
|
||||
export const TOPIC_LABEL: Record<string, string> = Object.fromEntries(
|
||||
TOPICS.map((t) => [t.tag, t.label])
|
||||
);
|
||||
271
frontend/lib/types.ts
Normal file
271
frontend/lib/types.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
export interface MemberTerm {
|
||||
congress?: number;
|
||||
chamber?: string;
|
||||
partyName?: string;
|
||||
stateCode?: string;
|
||||
stateName?: string;
|
||||
startYear?: number;
|
||||
endYear?: number;
|
||||
district?: number;
|
||||
}
|
||||
|
||||
export interface MemberLeadership {
|
||||
type?: string;
|
||||
congress?: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface MemberTrendScore {
|
||||
score_date: string;
|
||||
newsapi_count: number;
|
||||
gnews_count: number;
|
||||
gtrends_score: number;
|
||||
composite_score: number;
|
||||
}
|
||||
|
||||
export interface MemberNewsArticle {
|
||||
id: number;
|
||||
source?: string;
|
||||
headline?: string;
|
||||
url?: string;
|
||||
published_at?: string;
|
||||
relevance_score?: number;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
bioguide_id: string;
|
||||
name: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
party?: string;
|
||||
state?: string;
|
||||
chamber?: string;
|
||||
district?: string;
|
||||
photo_url?: string;
|
||||
official_url?: string;
|
||||
congress_url?: string;
|
||||
birth_year?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
terms_json?: MemberTerm[];
|
||||
leadership_json?: MemberLeadership[];
|
||||
sponsored_count?: number;
|
||||
cosponsored_count?: number;
|
||||
effectiveness_score?: number;
|
||||
effectiveness_percentile?: number;
|
||||
effectiveness_tier?: string;
|
||||
latest_trend?: MemberTrendScore;
|
||||
}
|
||||
|
||||
export interface CitedPoint {
|
||||
text: string;
|
||||
citation: string;
|
||||
quote: string;
|
||||
label?: "cited_fact" | "inference";
|
||||
}
|
||||
|
||||
export interface BriefSchema {
|
||||
id: number;
|
||||
brief_type?: string;
|
||||
summary?: string;
|
||||
key_points?: (string | CitedPoint)[];
|
||||
risks?: (string | CitedPoint)[];
|
||||
deadlines?: { date: string | null; description: string }[];
|
||||
topic_tags?: string[];
|
||||
llm_provider?: string;
|
||||
llm_model?: string;
|
||||
govinfo_url?: string;
|
||||
share_token?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface TrendScore {
|
||||
score_date: string;
|
||||
newsapi_count: number;
|
||||
gnews_count: number;
|
||||
gtrends_score: number;
|
||||
composite_score: number;
|
||||
}
|
||||
|
||||
export interface BillAction {
|
||||
id: number;
|
||||
action_date?: string;
|
||||
action_text?: string;
|
||||
action_type?: string;
|
||||
chamber?: string;
|
||||
}
|
||||
|
||||
export interface NewsArticle {
|
||||
id: number;
|
||||
source?: string;
|
||||
headline?: string;
|
||||
url?: string;
|
||||
published_at?: string;
|
||||
relevance_score?: number;
|
||||
}
|
||||
|
||||
export interface Bill {
|
||||
bill_id: string;
|
||||
congress_number: number;
|
||||
bill_type: string;
|
||||
bill_number: number;
|
||||
title?: string;
|
||||
short_title?: string;
|
||||
introduced_date?: string;
|
||||
latest_action_date?: string;
|
||||
latest_action_text?: string;
|
||||
status?: string;
|
||||
chamber?: string;
|
||||
congress_url?: string;
|
||||
sponsor?: Member;
|
||||
latest_brief?: BriefSchema;
|
||||
latest_trend?: TrendScore;
|
||||
updated_at?: string;
|
||||
has_document?: boolean;
|
||||
bill_category?: string;
|
||||
}
|
||||
|
||||
export interface AlignmentScore {
|
||||
bioguide_id: string;
|
||||
name: string;
|
||||
party?: string;
|
||||
state?: string;
|
||||
chamber?: string;
|
||||
photo_url?: string;
|
||||
effectiveness_percentile?: number;
|
||||
aligned: number;
|
||||
opposed: number;
|
||||
total: number;
|
||||
alignment_pct?: number;
|
||||
}
|
||||
|
||||
export interface AlignmentData {
|
||||
members: AlignmentScore[];
|
||||
total_bills_with_stance: number;
|
||||
total_bills_with_votes: number;
|
||||
}
|
||||
|
||||
export interface BillDetail extends Bill {
|
||||
actions: BillAction[];
|
||||
news_articles: NewsArticle[];
|
||||
trend_scores: TrendScore[];
|
||||
briefs: BriefSchema[];
|
||||
has_document: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface Follow {
|
||||
id: number;
|
||||
follow_type: "bill" | "member" | "topic";
|
||||
follow_value: string;
|
||||
follow_mode: "neutral" | "pocket_veto" | "pocket_boost";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
feed: Bill[];
|
||||
trending: Bill[];
|
||||
follows: { bills: number; members: number; topics: number };
|
||||
}
|
||||
|
||||
export interface SettingsData {
|
||||
llm_provider: string;
|
||||
llm_model: string;
|
||||
congress_poll_interval_minutes: number;
|
||||
newsapi_enabled: boolean;
|
||||
pytrends_enabled: boolean;
|
||||
api_keys_configured: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface BillNote {
|
||||
id: number;
|
||||
bill_id: string;
|
||||
content: string;
|
||||
pinned: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
ntfy_topic_url: string;
|
||||
ntfy_auth_method: string; // "none" | "token" | "basic"
|
||||
ntfy_token: string;
|
||||
ntfy_username: string;
|
||||
ntfy_password_set: boolean;
|
||||
ntfy_enabled: boolean;
|
||||
rss_enabled: boolean;
|
||||
rss_token: string | null;
|
||||
email_enabled: boolean;
|
||||
email_address: string;
|
||||
digest_enabled: boolean;
|
||||
digest_frequency: "daily" | "weekly";
|
||||
quiet_hours_start: number | null;
|
||||
quiet_hours_end: number | null;
|
||||
timezone: string | null; // IANA name, e.g. "America/New_York"
|
||||
alert_filters: Record<string, Record<string, boolean | string[]>> | null;
|
||||
}
|
||||
|
||||
// Write-only — ntfy_password is accepted on PUT but never returned (use ntfy_password_set to check)
|
||||
export interface NotificationSettingsUpdate extends Omit<Partial<NotificationSettings>, "ntfy_password_set"> {
|
||||
ntfy_password?: string;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_public: boolean;
|
||||
share_token: string;
|
||||
bill_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CollectionDetail extends Collection {
|
||||
bills: Bill[];
|
||||
}
|
||||
|
||||
export interface MemberVotePosition {
|
||||
bioguide_id?: string;
|
||||
member_name?: string;
|
||||
party?: string;
|
||||
state?: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
export interface BillVote {
|
||||
id: number;
|
||||
congress: number;
|
||||
chamber: string;
|
||||
session: number;
|
||||
roll_number: number;
|
||||
question?: string;
|
||||
description?: string;
|
||||
vote_date?: string;
|
||||
yeas?: number;
|
||||
nays?: number;
|
||||
not_voting?: number;
|
||||
result?: string;
|
||||
source_url?: string;
|
||||
positions: MemberVotePosition[];
|
||||
}
|
||||
|
||||
export interface NotificationEvent {
|
||||
id: number;
|
||||
bill_id: string;
|
||||
event_type: "new_document" | "new_amendment" | "bill_updated";
|
||||
payload: {
|
||||
bill_title?: string;
|
||||
bill_label?: string;
|
||||
brief_summary?: string;
|
||||
bill_url?: string;
|
||||
} | null;
|
||||
dispatched_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
67
frontend/lib/utils.ts
Normal file
67
frontend/lib/utils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(date?: string | null): string {
|
||||
if (!date) return "—";
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function billLabel(billType: string, billNumber: number): string {
|
||||
const labels: Record<string, string> = {
|
||||
hr: "H.R.",
|
||||
s: "S.",
|
||||
hjres: "H.J.Res.",
|
||||
sjres: "S.J.Res.",
|
||||
hconres: "H.Con.Res.",
|
||||
sconres: "S.Con.Res.",
|
||||
hres: "H.Res.",
|
||||
sres: "S.Res.",
|
||||
};
|
||||
return `${labels[billType?.toLowerCase()] ?? billType?.toUpperCase()} ${billNumber}`;
|
||||
}
|
||||
|
||||
export function partyColor(party?: string): string {
|
||||
if (!party) return "text-muted-foreground";
|
||||
const p = party.toLowerCase();
|
||||
if (p.includes("democrat") || p === "d") return "text-blue-500";
|
||||
if (p.includes("republican") || p === "r") return "text-red-500";
|
||||
return "text-yellow-500";
|
||||
}
|
||||
|
||||
export function partyBadgeColor(party?: string): string {
|
||||
if (!party) return "bg-muted text-muted-foreground";
|
||||
const p = party.toLowerCase();
|
||||
if (p.includes("democrat") || p === "d") return "bg-blue-600 text-white";
|
||||
if (p.includes("republican") || p === "r") return "bg-red-600 text-white";
|
||||
return "bg-slate-500 text-white";
|
||||
}
|
||||
|
||||
export function congressLabel(congress: number): string {
|
||||
const lastTwo = congress % 100;
|
||||
if (lastTwo >= 11 && lastTwo <= 13) return `${congress}th Congress`;
|
||||
const suffixes: Record<number, string> = { 1: "st", 2: "nd", 3: "rd" };
|
||||
return `${congress}${suffixes[congress % 10] ?? "th"} Congress`;
|
||||
}
|
||||
|
||||
export function chamberBadgeColor(chamber?: string): string {
|
||||
if (!chamber) return "bg-muted text-muted-foreground";
|
||||
const c = chamber.toLowerCase();
|
||||
if (c === "senate") return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400";
|
||||
if (c.startsWith("house")) return "bg-slate-100 text-slate-600 dark:bg-slate-700/50 dark:text-slate-300";
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
export function trendColor(score?: number): string {
|
||||
if (!score) return "text-muted-foreground";
|
||||
if (score >= 70) return "text-red-500";
|
||||
if (score >= 40) return "text-yellow-500";
|
||||
return "text-green-500";
|
||||
}
|
||||
13
frontend/next.config.ts
Normal file
13
frontend/next.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "bioguide.congress.gov" },
|
||||
{ protocol: "https", hostname: "www.congress.gov" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
48
frontend/package.json
Normal file
48
frontend/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "civicstack-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"axios": "^1.7.9",
|
||||
"recharts": "^2.13.3",
|
||||
"next-themes": "^0.4.3",
|
||||
"lucide-react": "^0.468.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"zustand": "^5.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.2.0",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
27
frontend/stores/authStore.ts
Normal file
27
frontend/stores/authStore.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
setAuth: (token: string, user: AuthUser) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
setAuth: (token, user) => set({ token, user }),
|
||||
logout: () => set({ token: null, user: null }),
|
||||
}),
|
||||
{ name: "pocketveto-auth" }
|
||||
)
|
||||
);
|
||||
54
frontend/tailwind.config.ts
Normal file
54
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./lib/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user