feat: collections, watchlists, and shareable links (v0.9.0)

Phase 3 completion — Personal Workflow feature set is now complete.

Collections / Watchlists:
- New tables: collections (UUID share_token, slug, public/private) and
  collection_bills (unique bill-per-collection constraint)
- Full CRUD API at /api/collections with bill add/remove endpoints
- Public share endpoint /api/collections/share/{token} (no auth)
- /collections list page with inline create form and delete
- /collections/[id] detail page: inline rename, public toggle,
  copy-share-link, bill search/add/remove
- CollectionPicker bookmark-icon popover on bill detail pages
- Collections nav link in sidebar (auth-required)

Shareable Brief Links:
- share_token UUID column on bill_briefs (backfilled on migration)
- Unified public share router at /api/share (brief + collection)
- /share/brief/[token] — minimal layout, full AIBriefCard, CTAs
- /share/collection/[token] — minimal layout, bill list, CTA
- Share2 button in BriefPanel header row, "Link copied!" flash

AuthGuard: /collections → AUTH_REQUIRED; /share prefix → NO_SHELL_PATHS

Authored-By: Jack Levy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jack Levy
2026-03-01 23:23:45 -05:00
parent 22b68f9502
commit 9e5ac9b33d
21 changed files with 1429 additions and 7 deletions

View File

@@ -12,6 +12,7 @@ import { ActionTimeline } from "@/components/bills/ActionTimeline";
import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton";
import { CollectionPicker } from "@/components/bills/CollectionPicker";
import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) {
@@ -109,7 +110,10 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
)}
</p>
</div>
<FollowButton type="bill" value={bill.bill_id} supportsModes />
<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 */}

View 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>
);
}

View File

@@ -0,0 +1,163 @@
"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 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 className="flex items-center gap-2">
<Bookmark className="w-5 h-5 text-primary" />
<h1 className="text-xl font-bold">My Collections</h1>
</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>
<span className="text-xs text-muted-foreground">(share link works either way)</span>
</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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-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";
@@ -34,6 +34,14 @@ function typeBadge(briefType?: string) {
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} />;
@@ -57,6 +65,23 @@ export function BriefPanel({ briefs }: BriefPanelProps) {
</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} />

View 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>
);
}

View File

@@ -6,8 +6,8 @@ import { useAuthStore } from "@/stores/authStore";
import { Sidebar } from "./Sidebar";
import { MobileHeader } from "./MobileHeader";
const NO_SHELL_PATHS = ["/login", "/register"];
const AUTH_REQUIRED = ["/following", "/notifications"];
const NO_SHELL_PATHS = ["/login", "/register", "/share"];
const AUTH_REQUIRED = ["/following", "/notifications", "/collections"];
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -31,8 +31,8 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
if (!hydrated) return null;
// Login/register pages render without the app shell
if (NO_SHELL_PATHS.includes(pathname)) {
// Login/register/share pages render without the app shell
if (NO_SHELL_PATHS.some((p) => pathname.startsWith(p))) {
return <>{children}</>;
}

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
Bookmark,
LayoutDashboard,
FileText,
Users,
@@ -25,6 +26,7 @@ const NAV = [
{ 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: "/collections", label: "Collections", icon: Bookmark, adminOnly: false, requiresAuth: true },
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false, requiresAuth: true },
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false },
];

View File

@@ -3,6 +3,9 @@ import type {
Bill,
BillAction,
BillDetail,
BriefSchema,
Collection,
CollectionDetail,
DashboardData,
Follow,
Member,
@@ -86,6 +89,33 @@ export const billsAPI = {
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) =>

View File

@@ -72,6 +72,7 @@ export interface BriefSchema {
llm_provider?: string;
llm_model?: string;
govinfo_url?: string;
share_token?: string;
created_at?: string;
}
@@ -183,6 +184,20 @@ export interface NotificationSettings {
timezone: string | null; // IANA name, e.g. "America/New_York"
}
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 NotificationEvent {
id: number;
bill_id: string;