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

@@ -0,0 +1,52 @@
"""Add collections and collection_bills tables
Revision ID: 0015
Revises: 0014
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0015"
down_revision = "0014"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"collections",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("slug", sa.String(120), nullable=False),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"share_token",
postgresql.UUID(as_uuid=False),
nullable=False,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("share_token", name="uq_collections_share_token"),
sa.UniqueConstraint("user_id", "slug", name="uq_collections_user_slug"),
)
op.create_index("ix_collections_user_id", "collections", ["user_id"])
op.create_index("ix_collections_share_token", "collections", ["share_token"])
op.create_table(
"collection_bills",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("collection_id", sa.Integer(), sa.ForeignKey("collections.id", ondelete="CASCADE"), nullable=False),
sa.Column("bill_id", sa.String(), sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False),
sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("collection_id", "bill_id", name="uq_collection_bills_collection_bill"),
)
op.create_index("ix_collection_bills_collection_id", "collection_bills", ["collection_id"])
op.create_index("ix_collection_bills_bill_id", "collection_bills", ["bill_id"])
def downgrade():
op.drop_table("collection_bills")
op.drop_table("collections")

View File

@@ -0,0 +1,33 @@
"""Add share_token to bill_briefs
Revision ID: 0016
Revises: 0015
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0016"
down_revision = "0015"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"bill_briefs",
sa.Column(
"share_token",
postgresql.UUID(as_uuid=False),
nullable=True,
server_default=sa.text("gen_random_uuid()"),
),
)
op.create_unique_constraint("uq_brief_share_token", "bill_briefs", ["share_token"])
op.create_index("ix_brief_share_token", "bill_briefs", ["share_token"])
def downgrade():
op.drop_index("ix_brief_share_token", "bill_briefs")
op.drop_constraint("uq_brief_share_token", "bill_briefs")
op.drop_column("bill_briefs", "share_token")

View File

@@ -0,0 +1,319 @@
"""
Collections API — named, curated groups of bills with share links.
"""
import re
import unicodedata
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.dependencies import get_current_user
from app.database import get_db
from app.models.bill import Bill, BillDocument
from app.models.collection import Collection, CollectionBill
from app.models.user import User
from app.schemas.schemas import (
BillSchema,
CollectionCreate,
CollectionDetailSchema,
CollectionSchema,
CollectionUpdate,
)
router = APIRouter()
def _slugify(text: str) -> str:
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
text = re.sub(r"[^\w\s-]", "", text.lower())
return re.sub(r"[-\s]+", "-", text).strip("-")
async def _unique_slug(db: AsyncSession, user_id: int, name: str, exclude_id: int | None = None) -> str:
base = _slugify(name) or "collection"
slug = base
counter = 2
while True:
q = select(Collection).where(Collection.user_id == user_id, Collection.slug == slug)
if exclude_id is not None:
q = q.where(Collection.id != exclude_id)
existing = (await db.execute(q)).scalar_one_or_none()
if not existing:
return slug
slug = f"{base}-{counter}"
counter += 1
def _to_schema(collection: Collection) -> CollectionSchema:
return CollectionSchema(
id=collection.id,
name=collection.name,
slug=collection.slug,
is_public=collection.is_public,
share_token=collection.share_token,
bill_count=len(collection.collection_bills),
created_at=collection.created_at,
)
async def _detail_schema(db: AsyncSession, collection: Collection) -> CollectionDetailSchema:
"""Build CollectionDetailSchema with bills (including has_document)."""
cb_list = collection.collection_bills
bills = [cb.bill for cb in cb_list]
bill_ids = [b.bill_id for b in bills]
if bill_ids:
doc_result = await db.execute(
select(BillDocument.bill_id).where(BillDocument.bill_id.in_(bill_ids)).distinct()
)
bills_with_docs = {row[0] for row in doc_result}
else:
bills_with_docs = set()
bill_schemas = []
for bill in bills:
bs = BillSchema.model_validate(bill)
if bill.briefs:
bs.latest_brief = bill.briefs[0]
if bill.trend_scores:
bs.latest_trend = bill.trend_scores[0]
bs.has_document = bill.bill_id in bills_with_docs
bill_schemas.append(bs)
return CollectionDetailSchema(
id=collection.id,
name=collection.name,
slug=collection.slug,
is_public=collection.is_public,
share_token=collection.share_token,
bill_count=len(cb_list),
created_at=collection.created_at,
bills=bill_schemas,
)
async def _load_collection(db: AsyncSession, collection_id: int) -> Collection:
result = await db.execute(
select(Collection)
.options(
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.briefs),
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.trend_scores),
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.sponsor),
)
.where(Collection.id == collection_id)
)
return result.scalar_one_or_none()
# ── List ──────────────────────────────────────────────────────────────────────
@router.get("", response_model=list[CollectionSchema])
async def list_collections(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Collection)
.options(selectinload(Collection.collection_bills))
.where(Collection.user_id == current_user.id)
.order_by(Collection.created_at.desc())
)
collections = result.scalars().unique().all()
return [_to_schema(c) for c in collections]
# ── Create ────────────────────────────────────────────────────────────────────
@router.post("", response_model=CollectionSchema, status_code=201)
async def create_collection(
body: CollectionCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
name = body.name.strip()
if not 1 <= len(name) <= 100:
raise HTTPException(status_code=422, detail="name must be 1100 characters")
slug = await _unique_slug(db, current_user.id, name)
collection = Collection(
user_id=current_user.id,
name=name,
slug=slug,
is_public=body.is_public,
)
db.add(collection)
await db.flush()
await db.execute(select(Collection).where(Collection.id == collection.id)) # ensure loaded
await db.commit()
await db.refresh(collection)
# Load collection_bills for bill_count
result = await db.execute(
select(Collection)
.options(selectinload(Collection.collection_bills))
.where(Collection.id == collection.id)
)
collection = result.scalar_one()
return _to_schema(collection)
# ── Share (public — no auth) ──────────────────────────────────────────────────
@router.get("/share/{share_token}", response_model=CollectionDetailSchema)
async def get_collection_by_share_token(
share_token: str,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Collection)
.options(
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.briefs),
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.trend_scores),
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.sponsor),
)
.where(Collection.share_token == share_token)
)
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
return await _detail_schema(db, collection)
# ── Get (owner) ───────────────────────────────────────────────────────────────
@router.get("/{collection_id}", response_model=CollectionDetailSchema)
async def get_collection(
collection_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
collection = await _load_collection(db, collection_id)
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
return await _detail_schema(db, collection)
# ── Update ────────────────────────────────────────────────────────────────────
@router.patch("/{collection_id}", response_model=CollectionSchema)
async def update_collection(
collection_id: int,
body: CollectionUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Collection)
.options(selectinload(Collection.collection_bills))
.where(Collection.id == collection_id)
)
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
if body.name is not None:
name = body.name.strip()
if not 1 <= len(name) <= 100:
raise HTTPException(status_code=422, detail="name must be 1100 characters")
collection.name = name
collection.slug = await _unique_slug(db, current_user.id, name, exclude_id=collection_id)
if body.is_public is not None:
collection.is_public = body.is_public
await db.commit()
await db.refresh(collection)
result = await db.execute(
select(Collection)
.options(selectinload(Collection.collection_bills))
.where(Collection.id == collection_id)
)
collection = result.scalar_one()
return _to_schema(collection)
# ── Delete ────────────────────────────────────────────────────────────────────
@router.delete("/{collection_id}", status_code=204)
async def delete_collection(
collection_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Collection).where(Collection.id == collection_id))
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
await db.delete(collection)
await db.commit()
# ── Add bill ──────────────────────────────────────────────────────────────────
@router.post("/{collection_id}/bills/{bill_id}", status_code=204)
async def add_bill_to_collection(
collection_id: int,
bill_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Collection).where(Collection.id == collection_id))
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
bill = await db.get(Bill, bill_id)
if not bill:
raise HTTPException(status_code=404, detail="Bill not found")
existing = await db.execute(
select(CollectionBill).where(
CollectionBill.collection_id == collection_id,
CollectionBill.bill_id == bill_id,
)
)
if existing.scalar_one_or_none():
return # idempotent
db.add(CollectionBill(collection_id=collection_id, bill_id=bill_id))
await db.commit()
# ── Remove bill ───────────────────────────────────────────────────────────────
@router.delete("/{collection_id}/bills/{bill_id}", status_code=204)
async def remove_bill_from_collection(
collection_id: int,
bill_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Collection).where(Collection.id == collection_id))
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
cb_result = await db.execute(
select(CollectionBill).where(
CollectionBill.collection_id == collection_id,
CollectionBill.bill_id == bill_id,
)
)
cb = cb_result.scalar_one_or_none()
if not cb:
raise HTTPException(status_code=404, detail="Bill not in collection")
await db.delete(cb)
await db.commit()

113
backend/app/api/share.py Normal file
View File

@@ -0,0 +1,113 @@
"""
Public share router — no authentication required.
Serves shareable read-only views for briefs and collections.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.bill import Bill, BillDocument
from app.models.brief import BillBrief
from app.models.collection import Collection, CollectionBill
from app.schemas.schemas import (
BillSchema,
BriefSchema,
BriefShareResponse,
CollectionDetailSchema,
)
router = APIRouter()
# ── Brief share ───────────────────────────────────────────────────────────────
@router.get("/brief/{token}", response_model=BriefShareResponse)
async def get_shared_brief(
token: str,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BillBrief)
.options(
selectinload(BillBrief.bill).selectinload(Bill.sponsor),
selectinload(BillBrief.bill).selectinload(Bill.briefs),
selectinload(BillBrief.bill).selectinload(Bill.trend_scores),
)
.where(BillBrief.share_token == token)
)
brief = result.scalar_one_or_none()
if not brief:
raise HTTPException(status_code=404, detail="Brief not found")
bill = brief.bill
bill_schema = BillSchema.model_validate(bill)
if bill.briefs:
bill_schema.latest_brief = bill.briefs[0]
if bill.trend_scores:
bill_schema.latest_trend = bill.trend_scores[0]
doc_result = await db.execute(
select(BillDocument.bill_id).where(BillDocument.bill_id == bill.bill_id).limit(1)
)
bill_schema.has_document = doc_result.scalar_one_or_none() is not None
return BriefShareResponse(
brief=BriefSchema.model_validate(brief),
bill=bill_schema,
)
# ── Collection share ──────────────────────────────────────────────────────────
@router.get("/collection/{token}", response_model=CollectionDetailSchema)
async def get_shared_collection(
token: str,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Collection)
.options(
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.briefs),
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.trend_scores),
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.sponsor),
)
.where(Collection.share_token == token)
)
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
cb_list = collection.collection_bills
bills = [cb.bill for cb in cb_list]
bill_ids = [b.bill_id for b in bills]
if bill_ids:
doc_result = await db.execute(
select(BillDocument.bill_id).where(BillDocument.bill_id.in_(bill_ids)).distinct()
)
bills_with_docs = {row[0] for row in doc_result}
else:
bills_with_docs = set()
bill_schemas = []
for bill in bills:
bs = BillSchema.model_validate(bill)
if bill.briefs:
bs.latest_brief = bill.briefs[0]
if bill.trend_scores:
bs.latest_trend = bill.trend_scores[0]
bs.has_document = bill.bill_id in bills_with_docs
bill_schemas.append(bs)
return CollectionDetailSchema(
id=collection.id,
name=collection.name,
slug=collection.slug,
is_public=collection.is_public,
share_token=collection.share_token,
bill_count=len(cb_list),
created_at=collection.created_at,
bills=bill_schemas,
)

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications, notes
from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications, notes, collections, share
from app.config import settings as config
app = FastAPI(
@@ -29,3 +29,5 @@ app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(health.router, prefix="/api/health", tags=["health"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
app.include_router(notes.router, prefix="/api/notes", tags=["notes"])
app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
app.include_router(share.router, prefix="/api/share", tags=["share"])

View File

@@ -1,5 +1,6 @@
from app.models.bill import Bill, BillAction, BillDocument
from app.models.brief import BillBrief
from app.models.collection import Collection, CollectionBill
from app.models.follow import Follow
from app.models.member import Member
from app.models.member_interest import MemberTrendScore, MemberNewsArticle
@@ -17,6 +18,8 @@ __all__ = [
"BillDocument",
"BillBrief",
"BillNote",
"Collection",
"CollectionBill",
"Follow",
"Member",
"MemberTrendScore",

View File

@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Index
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -21,6 +22,7 @@ class BillBrief(Base):
llm_provider = Column(String(50))
llm_model = Column(String(100))
govinfo_url = Column(String, nullable=True)
share_token = Column(postgresql.UUID(as_uuid=False), nullable=True, server_default=func.gen_random_uuid())
created_at = Column(DateTime(timezone=True), server_default=func.now())
bill = relationship("Bill", back_populates="briefs")

View File

@@ -0,0 +1,51 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Collection(Base):
__tablename__ = "collections"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name = Column(String(100), nullable=False)
slug = Column(String(120), nullable=False)
is_public = Column(Boolean, nullable=False, default=False, server_default="false")
share_token = Column(UUID(as_uuid=False), nullable=False, server_default=func.gen_random_uuid())
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="collections")
collection_bills = relationship(
"CollectionBill",
cascade="all, delete-orphan",
order_by="CollectionBill.added_at.desc()",
)
__table_args__ = (
UniqueConstraint("user_id", "slug", name="uq_collections_user_slug"),
UniqueConstraint("share_token", name="uq_collections_share_token"),
Index("ix_collections_user_id", "user_id"),
Index("ix_collections_share_token", "share_token"),
)
class CollectionBill(Base):
__tablename__ = "collection_bills"
id = Column(Integer, primary_key=True, autoincrement=True)
collection_id = Column(Integer, ForeignKey("collections.id", ondelete="CASCADE"), nullable=False)
bill_id = Column(String, ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False)
added_at = Column(DateTime(timezone=True), server_default=func.now())
collection = relationship("Collection", back_populates="collection_bills")
bill = relationship("Bill")
__table_args__ = (
UniqueConstraint("collection_id", "bill_id", name="uq_collection_bills_collection_bill"),
Index("ix_collection_bills_collection_id", "collection_id"),
Index("ix_collection_bills_bill_id", "bill_id"),
)

View File

@@ -20,3 +20,4 @@ class User(Base):
follows = relationship("Follow", back_populates="user", cascade="all, delete-orphan")
notification_events = relationship("NotificationEvent", back_populates="user", cascade="all, delete-orphan")
bill_notes = relationship("BillNote", back_populates="user", cascade="all, delete-orphan")
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")

View File

@@ -140,6 +140,7 @@ class BriefSchema(BaseModel):
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
govinfo_url: Optional[str] = None
share_token: Optional[str] = None
created_at: Optional[datetime] = None
model_config = {"from_attributes": True}
@@ -295,3 +296,42 @@ class SettingsResponse(BaseModel):
congress_poll_interval_minutes: int
newsapi_enabled: bool
pytrends_enabled: bool
# ── Collections ────────────────────────────────────────────────────────────────
class CollectionCreate(BaseModel):
name: str
is_public: bool = False
def validate_name(self) -> str:
name = self.name.strip()
if not 1 <= len(name) <= 100:
raise ValueError("name must be 1100 characters")
return name
class CollectionUpdate(BaseModel):
name: Optional[str] = None
is_public: Optional[bool] = None
class CollectionSchema(BaseModel):
id: int
name: str
slug: str
is_public: bool
share_token: str
bill_count: int
created_at: datetime
model_config = {"from_attributes": True}
class CollectionDetailSchema(CollectionSchema):
bills: list[BillSchema]
class BriefShareResponse(BaseModel):
brief: BriefSchema
bill: BillSchema

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,8 +110,11 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
)}
</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">

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;