feat: v1.0.0 — UX polish, security hardening, code quality
UI/UX: - Bill detail page tab UI (Analysis / Timeline / Votes / Notes) - Topic tag pills on bill detail and listing pages — filtered to known topics, clickable, properly labelled via shared lib/topics.ts - Notes panel always-open in Notes tab; sign-in prompt for guests - Collapsible sidebar with icon-only mode and localStorage persistence - Bills page defaults to has-text filter enabled - Follow mode dropdown transparency fix - Favicon (Landmark icon, blue background) Security: - Fernet encryption for ntfy passwords at rest (app/core/crypto.py) - Separate ENCRYPTION_SECRET_KEY env var; falls back to JWT derivation - ntfy_password no longer returned in GET response — replaced with ntfy_password_set: bool; NotificationSettingsUpdate type for writes - JWT_SECRET_KEY fail-fast on startup if using default placeholder - get_optional_user catches (JWTError, ValueError) only, not Exception Bug fixes & code quality: - Dashboard N+1 topic query replaced with single OR query - notification_utils.py topic follower N+1 replaced with batch query - Note query in bill detail page gated on token (enabled: !!token) - search.py max_length=500 guard against oversized queries - CollectionCreate.validate_name wired up with @field_validator - LLM_RATE_LIMIT_RPM default raised from 10 to 50 Authored by: Jack Levy
This commit is contained in:
@@ -2,7 +2,7 @@ from datetime import date, timedelta
|
|||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from sqlalchemy import desc, select
|
from sqlalchemy import desc, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -92,15 +92,15 @@ async def get_dashboard(
|
|||||||
feed_bills.append(bill)
|
feed_bills.append(bill)
|
||||||
seen_ids.add(bill.bill_id)
|
seen_ids.add(bill.bill_id)
|
||||||
|
|
||||||
# 3. Bills matching followed topics
|
# 3. Bills matching followed topics (single query with OR across all topics)
|
||||||
for topic in followed_topics:
|
if followed_topics:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Bill)
|
select(Bill)
|
||||||
.options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores))
|
.options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores))
|
||||||
.join(BillBrief, Bill.bill_id == BillBrief.bill_id)
|
.join(BillBrief, Bill.bill_id == BillBrief.bill_id)
|
||||||
.where(BillBrief.topic_tags.contains([topic]))
|
.where(or_(*[BillBrief.topic_tags.contains([t]) for t in followed_topics]))
|
||||||
.order_by(desc(Bill.latest_action_date))
|
.order_by(desc(Bill.latest_action_date))
|
||||||
.limit(10)
|
.limit(20)
|
||||||
)
|
)
|
||||||
for bill in result.scalars().all():
|
for bill in result.scalars().all():
|
||||||
if bill.bill_id not in seen_ids:
|
if bill.bill_id not in seen_ids:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings as app_settings
|
from app.config import settings as app_settings
|
||||||
|
from app.core.crypto import decrypt_secret, encrypt_secret
|
||||||
from app.core.dependencies import get_current_user
|
from app.core.dependencies import get_current_user
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.notification import NotificationEvent
|
from app.models.notification import NotificationEvent
|
||||||
@@ -41,7 +42,7 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin
|
|||||||
ntfy_auth_method=prefs.get("ntfy_auth_method", "none"),
|
ntfy_auth_method=prefs.get("ntfy_auth_method", "none"),
|
||||||
ntfy_token=prefs.get("ntfy_token", ""),
|
ntfy_token=prefs.get("ntfy_token", ""),
|
||||||
ntfy_username=prefs.get("ntfy_username", ""),
|
ntfy_username=prefs.get("ntfy_username", ""),
|
||||||
ntfy_password=prefs.get("ntfy_password", ""),
|
ntfy_password_set=bool(decrypt_secret(prefs.get("ntfy_password", ""))),
|
||||||
ntfy_enabled=prefs.get("ntfy_enabled", False),
|
ntfy_enabled=prefs.get("ntfy_enabled", False),
|
||||||
rss_enabled=prefs.get("rss_enabled", False),
|
rss_enabled=prefs.get("rss_enabled", False),
|
||||||
rss_token=rss_token,
|
rss_token=rss_token,
|
||||||
@@ -88,7 +89,7 @@ async def update_notification_settings(
|
|||||||
if body.ntfy_username is not None:
|
if body.ntfy_username is not None:
|
||||||
prefs["ntfy_username"] = body.ntfy_username.strip()
|
prefs["ntfy_username"] = body.ntfy_username.strip()
|
||||||
if body.ntfy_password is not None:
|
if body.ntfy_password is not None:
|
||||||
prefs["ntfy_password"] = body.ntfy_password.strip()
|
prefs["ntfy_password"] = encrypt_secret(body.ntfy_password.strip())
|
||||||
if body.ntfy_enabled is not None:
|
if body.ntfy_enabled is not None:
|
||||||
prefs["ntfy_enabled"] = body.ntfy_enabled
|
prefs["ntfy_enabled"] = body.ntfy_enabled
|
||||||
if body.rss_enabled is not None:
|
if body.rss_enabled is not None:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def search(
|
async def search(
|
||||||
q: str = Query(..., min_length=2),
|
q: str = Query(..., min_length=2, max_length=500),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# Bill ID direct match
|
# Bill ID direct match
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +14,11 @@ class Settings(BaseSettings):
|
|||||||
JWT_SECRET_KEY: str = "change-me-in-production"
|
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||||
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
|
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
|
# Symmetric encryption for sensitive user prefs (ntfy password, etc.)
|
||||||
|
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
# Falls back to JWT_SECRET_KEY derivation if not set (not recommended for production)
|
||||||
|
ENCRYPTION_SECRET_KEY: str = ""
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://congress:congress@postgres:5432/pocketveto"
|
DATABASE_URL: str = "postgresql+asyncpg://congress:congress@postgres:5432/pocketveto"
|
||||||
SYNC_DATABASE_URL: str = "postgresql://congress:congress@postgres:5432/pocketveto"
|
SYNC_DATABASE_URL: str = "postgresql://congress:congress@postgres:5432/pocketveto"
|
||||||
@@ -40,9 +46,9 @@ class Settings(BaseSettings):
|
|||||||
OLLAMA_MODEL: str = "llama3.1"
|
OLLAMA_MODEL: str = "llama3.1"
|
||||||
|
|
||||||
# Max LLM requests per minute — Celery enforces this globally across all workers.
|
# Max LLM requests per minute — Celery enforces this globally across all workers.
|
||||||
# Safe defaults: free Gemini=15 RPM, Anthropic paid=50 RPM, OpenAI paid=500 RPM.
|
# Defaults: free Gemini=15 RPM, Anthropic paid=50 RPM, OpenAI paid=500 RPM.
|
||||||
# Raise this in .env once you confirm your API tier.
|
# Lower this in .env if you hit rate limit errors on a restricted tier.
|
||||||
LLM_RATE_LIMIT_RPM: int = 10
|
LLM_RATE_LIMIT_RPM: int = 50
|
||||||
|
|
||||||
# Google Civic Information API (zip → representative lookup)
|
# Google Civic Information API (zip → representative lookup)
|
||||||
# Free key: https://console.cloud.google.com/apis/library/civicinfo.googleapis.com
|
# Free key: https://console.cloud.google.com/apis/library/civicinfo.googleapis.com
|
||||||
@@ -54,6 +60,15 @@ class Settings(BaseSettings):
|
|||||||
# pytrends
|
# pytrends
|
||||||
PYTRENDS_ENABLED: bool = True
|
PYTRENDS_ENABLED: bool = True
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def check_secrets(self) -> "Settings":
|
||||||
|
if self.JWT_SECRET_KEY == "change-me-in-production":
|
||||||
|
raise ValueError(
|
||||||
|
"JWT_SECRET_KEY must be set to a secure random value in .env. "
|
||||||
|
"Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
# SMTP (Email notifications)
|
# SMTP (Email notifications)
|
||||||
SMTP_HOST: str = ""
|
SMTP_HOST: str = ""
|
||||||
SMTP_PORT: int = 587
|
SMTP_PORT: int = 587
|
||||||
|
|||||||
44
backend/app/core/crypto.py
Normal file
44
backend/app/core/crypto.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Symmetric encryption for sensitive user prefs (e.g. ntfy password).
|
||||||
|
|
||||||
|
Key priority:
|
||||||
|
1. ENCRYPTION_SECRET_KEY env var (recommended — dedicated key, easily rotatable)
|
||||||
|
2. Derived from JWT_SECRET_KEY (fallback for existing installs)
|
||||||
|
|
||||||
|
Generate a dedicated key:
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
_PREFIX = "enc:"
|
||||||
|
_fernet_instance: Fernet | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
global _fernet_instance
|
||||||
|
if _fernet_instance is None:
|
||||||
|
from app.config import settings
|
||||||
|
if settings.ENCRYPTION_SECRET_KEY:
|
||||||
|
# Use dedicated key directly (must be a valid 32-byte base64url key)
|
||||||
|
_fernet_instance = Fernet(settings.ENCRYPTION_SECRET_KEY.encode())
|
||||||
|
else:
|
||||||
|
# Fallback: derive from JWT secret
|
||||||
|
key_bytes = hashlib.sha256(settings.JWT_SECRET_KEY.encode()).digest()
|
||||||
|
_fernet_instance = Fernet(base64.urlsafe_b64encode(key_bytes))
|
||||||
|
return _fernet_instance
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(plaintext: str) -> str:
|
||||||
|
"""Encrypt a string and return a prefixed ciphertext."""
|
||||||
|
if not plaintext:
|
||||||
|
return plaintext
|
||||||
|
return _PREFIX + _fernet().encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(value: str) -> str:
|
||||||
|
"""Decrypt a value produced by encrypt_secret. Returns plaintext as-is (legacy support)."""
|
||||||
|
if not value or not value.startswith(_PREFIX):
|
||||||
|
return value # legacy plaintext — return unchanged
|
||||||
|
return _fernet().decrypt(value[len(_PREFIX):].encode()).decode()
|
||||||
@@ -40,7 +40,7 @@ async def get_optional_user(
|
|||||||
try:
|
try:
|
||||||
user_id = decode_token(token)
|
user_id = decode_token(token)
|
||||||
return await db.get(User, user_id)
|
return await db.get(User, user_id)
|
||||||
except Exception:
|
except (JWTError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any, Generic, Optional, TypeVar
|
from typing import Any, Generic, Optional, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
# ── Notifications ──────────────────────────────────────────────────────────────
|
# ── Notifications ──────────────────────────────────────────────────────────────
|
||||||
@@ -31,7 +31,7 @@ class NotificationSettingsResponse(BaseModel):
|
|||||||
ntfy_auth_method: str = "none" # none | token | basic
|
ntfy_auth_method: str = "none" # none | token | basic
|
||||||
ntfy_token: str = ""
|
ntfy_token: str = ""
|
||||||
ntfy_username: str = ""
|
ntfy_username: str = ""
|
||||||
ntfy_password: str = ""
|
ntfy_password_set: bool = False
|
||||||
ntfy_enabled: bool = False
|
ntfy_enabled: bool = False
|
||||||
rss_enabled: bool = False
|
rss_enabled: bool = False
|
||||||
rss_token: Optional[str] = None
|
rss_token: Optional[str] = None
|
||||||
@@ -315,11 +315,13 @@ class CollectionCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
is_public: bool = False
|
is_public: bool = False
|
||||||
|
|
||||||
def validate_name(self) -> str:
|
@field_validator("name")
|
||||||
name = self.name.strip()
|
@classmethod
|
||||||
if not 1 <= len(name) <= 100:
|
def validate_name(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not 1 <= len(v) <= 100:
|
||||||
raise ValueError("name must be 1–100 characters")
|
raise ValueError("name must be 1–100 characters")
|
||||||
return name
|
return v
|
||||||
|
|
||||||
|
|
||||||
class CollectionUpdate(BaseModel):
|
class CollectionUpdate(BaseModel):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from app.core.crypto import decrypt_secret
|
||||||
from app.database import get_sync_db
|
from app.database import get_sync_db
|
||||||
from app.models.follow import Follow
|
from app.models.follow import Follow
|
||||||
from app.models.notification import NotificationEvent
|
from app.models.notification import NotificationEvent
|
||||||
@@ -162,7 +163,7 @@ def dispatch_notifications(self):
|
|||||||
ntfy_auth_method = prefs.get("ntfy_auth_method", "none")
|
ntfy_auth_method = prefs.get("ntfy_auth_method", "none")
|
||||||
ntfy_token = prefs.get("ntfy_token", "").strip()
|
ntfy_token = prefs.get("ntfy_token", "").strip()
|
||||||
ntfy_username = prefs.get("ntfy_username", "").strip()
|
ntfy_username = prefs.get("ntfy_username", "").strip()
|
||||||
ntfy_password = prefs.get("ntfy_password", "").strip()
|
ntfy_password = decrypt_secret(prefs.get("ntfy_password", "").strip())
|
||||||
ntfy_enabled = prefs.get("ntfy_enabled", False)
|
ntfy_enabled = prefs.get("ntfy_enabled", False)
|
||||||
rss_enabled = prefs.get("rss_enabled", False)
|
rss_enabled = prefs.get("rss_enabled", False)
|
||||||
digest_enabled = prefs.get("digest_enabled", False)
|
digest_enabled = prefs.get("digest_enabled", False)
|
||||||
|
|||||||
@@ -129,16 +129,20 @@ def emit_topic_follow_notifications(
|
|||||||
from app.models.follow import Follow
|
from app.models.follow import Follow
|
||||||
from app.models.notification import NotificationEvent
|
from app.models.notification import NotificationEvent
|
||||||
|
|
||||||
# Collect unique followers across all matching tags, recording the first matching tag per user
|
# Single query for all topic followers, then deduplicate by user_id
|
||||||
|
all_follows = db.query(Follow).filter(
|
||||||
|
Follow.follow_type == "topic",
|
||||||
|
Follow.follow_value.in_(topic_tags),
|
||||||
|
).all()
|
||||||
|
|
||||||
seen_user_ids: set[int] = set()
|
seen_user_ids: set[int] = set()
|
||||||
followers = []
|
followers = []
|
||||||
follower_topic: dict[int, str] = {}
|
follower_topic: dict[int, str] = {}
|
||||||
for tag in topic_tags:
|
for follow in all_follows:
|
||||||
for follow in db.query(Follow).filter_by(follow_type="topic", follow_value=tag).all():
|
|
||||||
if follow.user_id not in seen_user_ids:
|
if follow.user_id not in seen_user_ids:
|
||||||
seen_user_ids.add(follow.user_id)
|
seen_user_ids.add(follow.user_id)
|
||||||
followers.append(follow)
|
followers.append(follow)
|
||||||
follower_topic[follow.user_id] = tag
|
follower_topic[follow.user_id] = follow.follow_value
|
||||||
|
|
||||||
if not followers:
|
if not followers:
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use, useEffect, useRef } from "react";
|
import { use, useEffect, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft, ExternalLink, FileX, User } from "lucide-react";
|
import { ArrowLeft, ExternalLink, FileX, Tag, User } from "lucide-react";
|
||||||
import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills";
|
import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { BriefPanel } from "@/components/bills/BriefPanel";
|
import { BriefPanel } from "@/components/bills/BriefPanel";
|
||||||
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
|
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
|
||||||
import { NotesPanel } from "@/components/bills/NotesPanel";
|
import { NotesPanel } from "@/components/bills/NotesPanel";
|
||||||
@@ -15,28 +16,34 @@ import { NewsPanel } from "@/components/bills/NewsPanel";
|
|||||||
import { FollowButton } from "@/components/shared/FollowButton";
|
import { FollowButton } from "@/components/shared/FollowButton";
|
||||||
import { CollectionPicker } from "@/components/bills/CollectionPicker";
|
import { CollectionPicker } from "@/components/bills/CollectionPicker";
|
||||||
import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
|
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 }> }) {
|
export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const billId = decodeURIComponent(id);
|
const billId = decodeURIComponent(id);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>("analysis");
|
||||||
|
|
||||||
|
const token = useAuthStore((s) => s.token);
|
||||||
const { data: bill, isLoading } = useBill(billId);
|
const { data: bill, isLoading } = useBill(billId);
|
||||||
const { data: trendData } = useBillTrend(billId, 30);
|
const { data: trendData } = useBillTrend(billId, 30);
|
||||||
const { data: newsArticles, refetch: refetchNews } = useBillNews(billId);
|
const { data: newsArticles, refetch: refetchNews } = useBillNews(billId);
|
||||||
|
|
||||||
// Fetch the user's note so we know if it's pinned before rendering
|
|
||||||
const { data: note } = useQuery({
|
const { data: note } = useQuery({
|
||||||
queryKey: ["note", billId],
|
queryKey: ["note", billId],
|
||||||
queryFn: () => import("@/lib/api").then((m) => m.notesAPI.get(billId)),
|
queryFn: () => import("@/lib/api").then((m) => m.notesAPI.get(billId)),
|
||||||
enabled: true,
|
enabled: !!token,
|
||||||
retry: false,
|
retry: false,
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// When the bill page is opened with no stored articles, the backend queues
|
|
||||||
// a Celery news-fetch task that takes a few seconds to complete.
|
|
||||||
// Retry up to 3 times (every 6 s) so articles appear without a manual refresh.
|
|
||||||
// newsRetryRef resets on bill navigation so each bill gets its own retry budget.
|
|
||||||
const newsRetryRef = useRef(0);
|
const newsRetryRef = useRef(0);
|
||||||
useEffect(() => { newsRetryRef.current = 0; }, [billId]);
|
useEffect(() => { newsRetryRef.current = 0; }, [billId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -119,28 +126,60 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
|
|
||||||
{/* Content grid */}
|
{/* Content grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
||||||
<div className="md:col-span-2 space-y-6">
|
<div className="md:col-span-2 space-y-4">
|
||||||
{/* Pinned note floats above briefs */}
|
{/* Pinned note always visible above tabs */}
|
||||||
{note?.pinned && <NotesPanel billId={bill.bill_id} />}
|
{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 ? (
|
{bill.briefs.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<BriefPanel briefs={bill.briefs} />
|
<BriefPanel briefs={bill.briefs} />
|
||||||
<DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} />
|
<DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} />
|
||||||
{!note?.pinned && <NotesPanel billId={bill.bill_id} />}
|
|
||||||
</>
|
</>
|
||||||
) : bill.has_document ? (
|
) : bill.has_document ? (
|
||||||
<>
|
|
||||||
<div className="bg-card border border-border rounded-lg p-6 text-center space-y-2">
|
<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-sm font-medium text-muted-foreground">Analysis pending</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Bill text was retrieved but has not yet been analyzed. Check back shortly.
|
Bill text was retrieved but has not yet been analyzed. Check back shortly.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!note?.pinned && <NotesPanel billId={bill.bill_id} />}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
<div className="bg-card border border-border rounded-lg p-6 space-y-3">
|
<div className="bg-card border border-border rounded-lg p-6 space-y-3">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<FileX className="w-4 h-4 shrink-0" />
|
<FileX className="w-4 h-4 shrink-0" />
|
||||||
@@ -163,16 +202,27 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!note?.pinned && <NotesPanel billId={bill.bill_id} />}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "timeline" && (
|
||||||
<ActionTimeline
|
<ActionTimeline
|
||||||
actions={bill.actions}
|
actions={bill.actions}
|
||||||
latestActionDate={bill.latest_action_date}
|
latestActionDate={bill.latest_action_date}
|
||||||
latestActionText={bill.latest_action_text}
|
latestActionText={bill.latest_action_text}
|
||||||
/>
|
/>
|
||||||
<VotePanel billId={bill.bill_id} />
|
)}
|
||||||
|
|
||||||
|
{activeTab === "votes" && (
|
||||||
|
<VotePanel billId={bill.bill_id} alwaysRender />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "notes" && (
|
||||||
|
<NotesPanel billId={bill.bill_id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TrendChart data={trendData} />
|
<TrendChart data={trendData} />
|
||||||
<NewsPanel articles={newsArticles} />
|
<NewsPanel articles={newsArticles} />
|
||||||
|
|||||||
@@ -5,21 +5,16 @@ import { useSearchParams } from "next/navigation";
|
|||||||
import { FileText, Search } from "lucide-react";
|
import { FileText, Search } from "lucide-react";
|
||||||
import { useBills } from "@/lib/hooks/useBills";
|
import { useBills } from "@/lib/hooks/useBills";
|
||||||
import { BillCard } from "@/components/shared/BillCard";
|
import { BillCard } from "@/components/shared/BillCard";
|
||||||
|
import { TOPICS } from "@/lib/topics";
|
||||||
|
|
||||||
const CHAMBERS = ["", "House", "Senate"];
|
const CHAMBERS = ["", "House", "Senate"];
|
||||||
const TOPICS = [
|
|
||||||
"", "healthcare", "taxation", "defense", "education", "immigration",
|
|
||||||
"environment", "housing", "infrastructure", "technology", "agriculture",
|
|
||||||
"judiciary", "foreign-policy", "veterans", "social-security", "trade",
|
|
||||||
"budget", "energy", "banking", "transportation", "labor",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function BillsPage() {
|
export default function BillsPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [q, setQ] = useState(searchParams.get("q") ?? "");
|
const [q, setQ] = useState(searchParams.get("q") ?? "");
|
||||||
const [chamber, setChamber] = useState(searchParams.get("chamber") ?? "");
|
const [chamber, setChamber] = useState(searchParams.get("chamber") ?? "");
|
||||||
const [topic, setTopic] = useState(searchParams.get("topic") ?? "");
|
const [topic, setTopic] = useState(searchParams.get("topic") ?? "");
|
||||||
const [hasText, setHasText] = useState(false);
|
const [hasText, setHasText] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
// Sync URL params → state so tag/topic links work when already on this page
|
// Sync URL params → state so tag/topic links work when already on this page
|
||||||
@@ -75,7 +70,7 @@ export default function BillsPage() {
|
|||||||
className="px-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
|
className="px-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">All Topics</option>
|
<option value="">All Topics</option>
|
||||||
{TOPICS.slice(1).map((t) => <option key={t} value={t}>{t}</option>)}
|
{TOPICS.map((t) => <option key={t.tag} value={t.tag}>{t.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setHasText((v) => !v); setPage(1); }}
|
onClick={() => { setHasText((v) => !v); setPage(1); }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
BarChart2,
|
||||||
Bell,
|
Bell,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -8,9 +9,15 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Heart,
|
Heart,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
ListChecks,
|
||||||
|
Mail,
|
||||||
|
MessageSquare,
|
||||||
Rss,
|
Rss,
|
||||||
Shield,
|
Shield,
|
||||||
Share2,
|
Share2,
|
||||||
|
StickyNote,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -67,7 +74,12 @@ export default function HowItWorksPage() {
|
|||||||
{ href: "#collections", label: "Collections" },
|
{ href: "#collections", label: "Collections" },
|
||||||
{ href: "#notifications", label: "Notifications" },
|
{ href: "#notifications", label: "Notifications" },
|
||||||
{ href: "#briefs", label: "AI Briefs" },
|
{ href: "#briefs", label: "AI Briefs" },
|
||||||
|
{ href: "#votes", label: "Votes" },
|
||||||
|
{ href: "#alignment", label: "Alignment" },
|
||||||
|
{ href: "#notes", label: "Notes" },
|
||||||
{ href: "#bills", label: "Bills" },
|
{ href: "#bills", label: "Bills" },
|
||||||
|
{ href: "#members-topics", label: "Members & Topics" },
|
||||||
|
{ href: "#dashboard", label: "Dashboard" },
|
||||||
].map(({ href, label }) => (
|
].map(({ href, label }) => (
|
||||||
<a
|
<a
|
||||||
key={href}
|
key={href}
|
||||||
@@ -143,7 +155,7 @@ export default function HowItWorksPage() {
|
|||||||
</Item>
|
</Item>
|
||||||
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing">
|
<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 —
|
Every collection has a unique share link. Anyone with the link can view the collection —
|
||||||
no account required. The link works whether the collection is public or private.
|
no account required.
|
||||||
</Item>
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -156,37 +168,40 @@ export default function HowItWorksPage() {
|
|||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<Section id="notifications" title="Notifications" icon={Bell}>
|
<Section id="notifications" title="Notifications" icon={Bell}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
PocketVeto delivers alerts through two independent channels — use either or both.
|
PocketVeto delivers alerts through three independent channels — use any combination.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<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">
|
<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">
|
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a>
|
||||||
ntfy
|
|
||||||
</a>
|
|
||||||
{" "}is a free, open-source push notification service. Configure a topic URL in{" "}
|
{" "}is a free, open-source push notification service. Configure a topic URL in{" "}
|
||||||
<Link href="/notifications" className="text-primary hover:underline">Notifications</Link>{" "}
|
<Link href="/notifications" className="text-primary hover:underline">Notifications</Link>{" "}
|
||||||
and receive real-time alerts on any device with the ntfy app.
|
and receive real-time alerts on any device with the ntfy app.
|
||||||
</Item>
|
</Item>
|
||||||
<Item icon={Clock} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Quiet hours">
|
<Item icon={Mail} color="bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400" title="Email">
|
||||||
Pause push notifications during set hours (e.g. 10 PM – 8 AM). Events that arrive
|
Receive alerts as plain-text emails. Add your address in{" "}
|
||||||
during quiet hours are queued and sent as a batch when the window ends.
|
<Link href="/notifications" className="text-primary hover:underline">Notifications → Email</Link>.
|
||||||
</Item>
|
Every email includes a one-click unsubscribe link, and your address is never used for
|
||||||
<Item icon={Calendar} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Digest mode">
|
anything other than bill alerts.
|
||||||
Instead of one push 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>
|
||||||
<Item icon={Rss} color="bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400" title="RSS feed">
|
<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
|
A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader
|
||||||
(Feedly, NetNewsWire, etc.). Completely independent of ntfy.
|
(Feedly, NetNewsWire, etc.). Always real-time, completely independent of the other channels.
|
||||||
</Item>
|
</Item>
|
||||||
<Item icon={Filter} color="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" title="Discovery alerts">
|
<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
|
Member and topic follows generate Discovery alerts — separate from the bills you follow
|
||||||
directly. In{" "}
|
directly. In{" "}
|
||||||
<Link href="/notifications" className="text-primary hover:underline">Alert Filters → Discovery</Link>,
|
<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
|
you can enable or disable these independently, tune which event types trigger them, and
|
||||||
mute specific members or topics you'd rather not hear about without unfollowing them.
|
mute specific members or topics without unfollowing them. Each notification includes a
|
||||||
Each notification also shows a “why” line so you always know which follow
|
“why” line so you always know which follow triggered it.
|
||||||
triggered it.
|
|
||||||
</Item>
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -194,29 +209,86 @@ export default function HowItWorksPage() {
|
|||||||
{/* AI Briefs */}
|
{/* AI Briefs */}
|
||||||
<Section id="briefs" title="AI Briefs" icon={FileText}>
|
<Section id="briefs" title="AI Briefs" icon={FileText}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
For bills with published official text, PocketVeto generates a plain-English AI brief.
|
For bills with published official text, PocketVeto generates a plain-English AI brief
|
||||||
|
automatically — no action needed on your part.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<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">
|
<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
|
A plain-English summary, key policy points with references to specific bill sections
|
||||||
(§ chips), and a risks section that flags potential unintended consequences or contested
|
(§ chips you can expand to see the quoted source text), and a risks section that flags
|
||||||
provisions.
|
potential unintended consequences or contested provisions.
|
||||||
</Item>
|
</Item>
|
||||||
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing a brief">
|
<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
|
Click the share icon in the brief panel to copy a public link. Anyone can read the
|
||||||
brief at that URL — no login required.
|
brief at that URL — no login required.
|
||||||
</Item>
|
</Item>
|
||||||
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Draft a letter">
|
<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 (below the brief) to generate a personalised letter to your
|
Use the Draft Letter panel in the Analysis tab to generate a personalised letter to
|
||||||
representative based on the brief's key points.
|
your representative based on the brief's key points.
|
||||||
</Item>
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Briefs are only generated for bills where GovInfo has published official text. Bills
|
Briefs are only generated for bills where GovInfo has published official text. Bills
|
||||||
without text show a “No text” badge on their card.
|
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>
|
</p>
|
||||||
</Section>
|
</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 */}
|
{/* Bills */}
|
||||||
<Section id="bills" title="Browsing bills" icon={FileText}>
|
<Section id="bills" title="Browsing bills" icon={FileText}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -227,13 +299,55 @@ export default function HowItWorksPage() {
|
|||||||
<p><strong className="text-foreground">Search</strong> — matches bill ID, title, and short title.</p>
|
<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">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">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 available for AI briefing.</p>
|
<p><strong className="text-foreground">Has text</strong> — show only bills with published official text. On by default.</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Clicking a topic tag on any bill or following page takes you directly to that filtered
|
Each bill page is organised into four tabs: <strong>Analysis</strong> (AI brief + draft
|
||||||
view on the Bills page.
|
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>
|
</p>
|
||||||
</Section>
|
</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>
|
</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 |
@@ -297,7 +297,7 @@ export default function NotificationsPage() {
|
|||||||
setAuthMethod(settings.ntfy_auth_method ?? "none");
|
setAuthMethod(settings.ntfy_auth_method ?? "none");
|
||||||
setToken(settings.ntfy_token ?? "");
|
setToken(settings.ntfy_token ?? "");
|
||||||
setUsername(settings.ntfy_username ?? "");
|
setUsername(settings.ntfy_username ?? "");
|
||||||
setPassword(settings.ntfy_password ?? "");
|
setPassword(""); // never pre-fill — password_set bool shows whether one is stored
|
||||||
setEmailAddress(settings.email_address ?? "");
|
setEmailAddress(settings.email_address ?? "");
|
||||||
setEmailEnabled(settings.email_enabled ?? false);
|
setEmailEnabled(settings.email_enabled ?? false);
|
||||||
setDigestEnabled(settings.digest_enabled ?? false);
|
setDigestEnabled(settings.digest_enabled ?? false);
|
||||||
@@ -333,7 +333,7 @@ export default function NotificationsPage() {
|
|||||||
ntfy_auth_method: authMethod,
|
ntfy_auth_method: authMethod,
|
||||||
ntfy_token: authMethod === "token" ? token : "",
|
ntfy_token: authMethod === "token" ? token : "",
|
||||||
ntfy_username: authMethod === "basic" ? username : "",
|
ntfy_username: authMethod === "basic" ? username : "",
|
||||||
ntfy_password: authMethod === "basic" ? password : "",
|
ntfy_password: authMethod === "basic" ? (password || undefined) : "",
|
||||||
ntfy_enabled: enabled,
|
ntfy_enabled: enabled,
|
||||||
},
|
},
|
||||||
{ onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } }
|
{ onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } }
|
||||||
@@ -565,7 +565,9 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Password</label>
|
<label className="text-sm font-medium">Password</label>
|
||||||
<input type="password" placeholder="your-password" value={password}
|
<input type="password"
|
||||||
|
placeholder={settings?.ntfy_password_set && !password ? "••••••• (saved — leave blank to keep)" : "your-password"}
|
||||||
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
|
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,29 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Tags } from "lucide-react";
|
import { Tags } from "lucide-react";
|
||||||
import { FollowButton } from "@/components/shared/FollowButton";
|
import { FollowButton } from "@/components/shared/FollowButton";
|
||||||
|
import { TOPICS } from "@/lib/topics";
|
||||||
const TOPICS = [
|
|
||||||
{ tag: "healthcare", label: "Healthcare", desc: "Health policy, insurance, Medicare, Medicaid" },
|
|
||||||
{ tag: "taxation", label: "Taxation", desc: "Tax law, IRS, fiscal policy" },
|
|
||||||
{ tag: "defense", label: "Defense", desc: "Military, NDAA, national security" },
|
|
||||||
{ tag: "education", label: "Education", desc: "Schools, student loans, higher education" },
|
|
||||||
{ tag: "immigration", label: "Immigration", desc: "Border, visas, asylum, citizenship" },
|
|
||||||
{ tag: "environment", label: "Environment", desc: "Climate, EPA, conservation, energy" },
|
|
||||||
{ tag: "housing", label: "Housing", desc: "Affordable housing, mortgages, HUD" },
|
|
||||||
{ tag: "infrastructure", label: "Infrastructure", desc: "Roads, bridges, broadband, transit" },
|
|
||||||
{ tag: "technology", label: "Technology", desc: "AI, cybersecurity, telecom, internet" },
|
|
||||||
{ tag: "agriculture", label: "Agriculture", desc: "Farm bill, USDA, food policy" },
|
|
||||||
{ tag: "judiciary", label: "Judiciary", desc: "Courts, criminal justice, civil rights" },
|
|
||||||
{ tag: "foreign-policy", label: "Foreign Policy", desc: "Diplomacy, foreign aid, sanctions" },
|
|
||||||
{ tag: "veterans", label: "Veterans", desc: "VA, veteran benefits, military families" },
|
|
||||||
{ tag: "social-security", label: "Social Security", desc: "SS, Medicare, retirement benefits" },
|
|
||||||
{ tag: "trade", label: "Trade", desc: "Tariffs, trade agreements, WTO" },
|
|
||||||
{ tag: "budget", label: "Budget", desc: "Appropriations, debt ceiling, spending" },
|
|
||||||
{ tag: "energy", label: "Energy", desc: "Oil, gas, renewables, nuclear" },
|
|
||||||
{ tag: "banking", label: "Banking", desc: "Financial regulation, Fed, CFPB" },
|
|
||||||
{ tag: "transportation", label: "Transportation", desc: "FAA, DOT, aviation, rail" },
|
|
||||||
{ tag: "labor", label: "Labor", desc: "Minimum wage, unions, OSHA, employment" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TopicsPage() {
|
export default function TopicsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AlertTriangle, CheckCircle, Clock, Cpu, ExternalLink, Tag } from "lucide-react";
|
import { AlertTriangle, CheckCircle, Clock, Cpu, ExternalLink } from "lucide-react";
|
||||||
import { BriefSchema, CitedPoint } from "@/lib/types";
|
import { BriefSchema, CitedPoint } from "@/lib/types";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -30,20 +30,23 @@ function CitedItem({ point, icon, govinfo_url, openKey, activeKey, setActiveKey
|
|||||||
<li className="text-sm">
|
<li className="text-sm">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
<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>
|
<span className="flex-1">{cited ? point.text : point}</span>
|
||||||
{cited && point.label === "inference" && (
|
{cited && point.label === "inference" && (
|
||||||
<span
|
<span
|
||||||
title="This point is an analytical interpretation, not a literal statement from the bill text"
|
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"
|
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
|
Inferred
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{cited && (
|
{cited && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveKey(isOpen ? null : openKey)}
|
onClick={() => setActiveKey(isOpen ? null : openKey)}
|
||||||
title={isOpen ? "Hide source" : "View source"}
|
title={isOpen ? "Hide source" : "View source"}
|
||||||
className={`shrink-0 text-xs px-1.5 py-0.5 rounded font-mono transition-colors ${
|
className={`text-left text-xs px-1.5 py-0.5 rounded font-mono leading-snug transition-colors ${
|
||||||
isOpen
|
isOpen
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
@@ -53,6 +56,7 @@ function CitedItem({ point, icon, govinfo_url, openKey, activeKey, setActiveKey
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{cited && isOpen && (
|
{cited && isOpen && (
|
||||||
<div className="mt-2 ml-5 rounded-md border border-border bg-muted/40 p-3 space-y-2">
|
<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">
|
<blockquote className="text-xs text-muted-foreground italic leading-relaxed border-l-2 border-primary pl-3">
|
||||||
@@ -165,19 +169,6 @@ export function AIBriefCard({ brief }: AIBriefCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{brief.topic_tags && brief.topic_tags.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 pt-1 border-t border-border flex-wrap">
|
|
||||||
<Tag className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
||||||
{brief.topic_tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="text-xs px-2 py-1 bg-accent text-accent-foreground rounded-full"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { StickyNote, Pin, PinOff, Trash2, ChevronDown, ChevronUp, Save } from "lucide-react";
|
import { Pin, PinOff, Trash2, Save } from "lucide-react";
|
||||||
import { notesAPI } from "@/lib/api";
|
import { notesAPI } from "@/lib/api";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@ export function NotesPanel({ billId }: NotesPanelProps) {
|
|||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [pinned, setPinned] = useState(false);
|
const [pinned, setPinned] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
@@ -43,7 +42,7 @@ export function NotesPanel({ billId }: NotesPanelProps) {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.style.height = "auto";
|
el.style.height = "auto";
|
||||||
el.style.height = `${el.scrollHeight}px`;
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
}, [content, open]);
|
}, [content]);
|
||||||
|
|
||||||
const upsert = useMutation({
|
const upsert = useMutation({
|
||||||
mutationFn: () => notesAPI.upsert(billId, content, pinned),
|
mutationFn: () => notesAPI.upsert(billId, content, pinned),
|
||||||
@@ -60,12 +59,14 @@ export function NotesPanel({ billId }: NotesPanelProps) {
|
|||||||
qc.removeQueries({ queryKey });
|
qc.removeQueries({ queryKey });
|
||||||
setContent("");
|
setContent("");
|
||||||
setPinned(false);
|
setPinned(false);
|
||||||
setOpen(false);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't render for guests
|
if (!token) return (
|
||||||
if (!token) return null;
|
<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;
|
if (isLoading) return null;
|
||||||
|
|
||||||
const hasNote = !!note;
|
const hasNote = !!note;
|
||||||
@@ -74,27 +75,7 @@ export function NotesPanel({ billId }: NotesPanelProps) {
|
|||||||
: content.trim().length > 0;
|
: content.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||||
{/* Header / toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="w-full flex items-center justify-between px-4 py-3 text-sm hover:bg-accent transition-colors"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 font-medium">
|
|
||||||
<StickyNote className="w-4 h-4 text-muted-foreground" />
|
|
||||||
My Note
|
|
||||||
{hasNote && (
|
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground font-normal">
|
|
||||||
{note.pinned && <Pin className="w-3 h-3" />}
|
|
||||||
{new Date(note.updated_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{open ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="px-4 pb-4 space-y-3 border-t border-border pt-3">
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={content}
|
value={content}
|
||||||
@@ -109,7 +90,7 @@ export function NotesPanel({ billId }: NotesPanelProps) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPinned((v) => !v)}
|
onClick={() => setPinned((v) => !v)}
|
||||||
title={pinned ? "Unpin note" : "Pin to top of page"}
|
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 ${
|
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
|
||||||
pinned
|
pinned
|
||||||
? "border-primary text-primary bg-primary/10"
|
? "border-primary text-primary bg-primary/10"
|
||||||
@@ -145,7 +126,5 @@ export function NotesPanel({ billId }: NotesPanelProps) {
|
|||||||
|
|
||||||
<p className="text-xs text-muted-foreground">Private — only visible to you.</p>
|
<p className="text-xs text-muted-foreground">Private — only visible to you.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import type { BillVote, MemberVotePosition } from "@/lib/types";
|
|||||||
|
|
||||||
interface VotePanelProps {
|
interface VotePanelProps {
|
||||||
billId: string;
|
billId: string;
|
||||||
|
alwaysRender?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VotePanel({ billId }: VotePanelProps) {
|
export function VotePanel({ billId, alwaysRender = false }: VotePanelProps) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
const { data: votes, isLoading } = useQuery({
|
const { data: votes, isLoading } = useQuery({
|
||||||
@@ -33,7 +34,16 @@ export function VotePanel({ billId }: VotePanelProps) {
|
|||||||
.map((f) => f.follow_value)
|
.map((f) => f.follow_value)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || !votes || votes.length === 0) return null;
|
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 (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TrendingUp, Calendar, User, FileText, FileClock, FileX, Tag } from "luc
|
|||||||
import { Bill } from "@/lib/types";
|
import { Bill } from "@/lib/types";
|
||||||
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils";
|
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils";
|
||||||
import { FollowButton } from "./FollowButton";
|
import { FollowButton } from "./FollowButton";
|
||||||
|
import { TOPIC_LABEL, TOPIC_TAGS } from "@/lib/topics";
|
||||||
|
|
||||||
interface BillCardProps {
|
interface BillCardProps {
|
||||||
bill: Bill;
|
bill: Bill;
|
||||||
@@ -12,7 +13,7 @@ interface BillCardProps {
|
|||||||
export function BillCard({ bill, compact = false }: BillCardProps) {
|
export function BillCard({ bill, compact = false }: BillCardProps) {
|
||||||
const label = billLabel(bill.bill_type, bill.bill_number);
|
const label = billLabel(bill.bill_type, bill.bill_number);
|
||||||
const score = bill.latest_trend?.composite_score;
|
const score = bill.latest_trend?.composite_score;
|
||||||
const tags = bill.latest_brief?.topic_tags?.slice(0, 3) || [];
|
const tags = (bill.latest_brief?.topic_tags || []).filter((t) => TOPIC_TAGS.has(t)).slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg p-4 hover:border-primary/30 transition-colors">
|
<div className="bg-card border border-border rounded-lg p-4 hover:border-primary/30 transition-colors">
|
||||||
@@ -35,7 +36,7 @@ export function BillCard({ bill, compact = false }: BillCardProps) {
|
|||||||
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"
|
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" />
|
<Tag className="w-2.5 h-2.5" />
|
||||||
{tag}
|
{TOPIC_LABEL[tag] ?? tag}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 mt-1 w-64 bg-popover border border-border rounded-md shadow-lg z-50 py-1">
|
<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) => {
|
{otherModes.map((mode) => {
|
||||||
const { label: optLabel, icon: OptIcon } = MODES[mode];
|
const { label: optLabel, icon: OptIcon } = MODES[mode];
|
||||||
return (
|
return (
|
||||||
@@ -161,7 +161,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
|
|||||||
key={mode}
|
key={mode}
|
||||||
onClick={() => switchMode(mode)}
|
onClick={() => switchMode(mode)}
|
||||||
title={modeDescriptions[mode]}
|
title={modeDescriptions[mode]}
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex flex-col gap-0.5"
|
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">
|
<span className="flex items-center gap-1.5 font-medium">
|
||||||
<OptIcon className="w-3.5 h-3.5" />
|
<OptIcon className="w-3.5 h-3.5" />
|
||||||
@@ -174,7 +174,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
|
|||||||
<div className="border-t border-border mt-1 pt-1">
|
<div className="border-t border-border mt-1 pt-1">
|
||||||
<button
|
<button
|
||||||
onClick={handleUnfollow}
|
onClick={handleUnfollow}
|
||||||
className="w-full text-left px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors"
|
className="w-full text-left px-3 py-2 text-sm bg-card hover:bg-accent text-destructive transition-colors"
|
||||||
>
|
>
|
||||||
Unfollow
|
Unfollow
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -42,6 +45,23 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
const logout = useAuthStore((s) => s.logout);
|
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() {
|
function handleLogout() {
|
||||||
logout();
|
logout();
|
||||||
@@ -50,18 +70,38 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col">
|
<aside
|
||||||
<div className="p-4 border-b border-border flex items-center gap-2">
|
className={cn(
|
||||||
<Landmark className="w-5 h-5 text-primary" />
|
"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>
|
<span className="font-semibold text-sm flex-1">PocketVeto</span>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button onClick={onClose} className="p-1 rounded-md hover:bg-accent transition-colors" aria-label="Close menu">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-md hover:bg-accent transition-colors"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 p-3 space-y-1">
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 p-2 space-y-0.5">
|
||||||
{NAV.filter(({ adminOnly, requiresAuth }) => {
|
{NAV.filter(({ adminOnly, requiresAuth }) => {
|
||||||
if (adminOnly && !user?.is_admin) return false;
|
if (adminOnly && !user?.is_admin) return false;
|
||||||
if (requiresAuth && !token) return false;
|
if (requiresAuth && !token) return false;
|
||||||
@@ -73,28 +113,32 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
|
|||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
title={isCollapsed ? label : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
|
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
|
||||||
|
isCollapsed && "justify-center px-0",
|
||||||
active
|
active
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 shrink-0" />
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
{label}
|
{!isCollapsed && label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-3 border-t border-border space-y-2">
|
{/* Footer */}
|
||||||
|
<div className={cn("p-3 border-t border-border space-y-2", isCollapsed && "p-2")}>
|
||||||
{token ? (
|
{token ? (
|
||||||
<>
|
user && (
|
||||||
{user && (
|
<div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
|
||||||
<div className="flex items-center justify-between">
|
{!isCollapsed && (
|
||||||
<span className="text-xs text-muted-foreground truncate max-w-[120px]" title={user.email}>
|
<span className="text-xs text-muted-foreground truncate max-w-[120px]" title={user.email}>
|
||||||
{user.email}
|
{user.email}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent"
|
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
@@ -103,22 +147,41 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
|
|||||||
<LogOut className="w-3.5 h-3.5" />
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
</>
|
) : !isCollapsed ? (
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
<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
|
Register
|
||||||
</Link>
|
</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">
|
<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
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-muted-foreground">Theme</span>
|
<div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
|
||||||
|
{!isCollapsed && <span className="text-xs text-muted-foreground">Theme</span>}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
NewsArticle,
|
NewsArticle,
|
||||||
NotificationEvent,
|
NotificationEvent,
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
|
NotificationSettingsUpdate,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
SettingsData,
|
SettingsData,
|
||||||
TrendScore,
|
TrendScore,
|
||||||
@@ -235,7 +236,7 @@ export interface NotificationTestResult {
|
|||||||
export const notificationsAPI = {
|
export const notificationsAPI = {
|
||||||
getSettings: () =>
|
getSettings: () =>
|
||||||
apiClient.get<NotificationSettings>("/api/notifications/settings").then((r) => r.data),
|
apiClient.get<NotificationSettings>("/api/notifications/settings").then((r) => r.data),
|
||||||
updateSettings: (data: Partial<NotificationSettings>) =>
|
updateSettings: (data: NotificationSettingsUpdate) =>
|
||||||
apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
|
apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
|
||||||
resetRssToken: () =>
|
resetRssToken: () =>
|
||||||
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data),
|
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data),
|
||||||
|
|||||||
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])
|
||||||
|
);
|
||||||
@@ -198,7 +198,7 @@ export interface NotificationSettings {
|
|||||||
ntfy_auth_method: string; // "none" | "token" | "basic"
|
ntfy_auth_method: string; // "none" | "token" | "basic"
|
||||||
ntfy_token: string;
|
ntfy_token: string;
|
||||||
ntfy_username: string;
|
ntfy_username: string;
|
||||||
ntfy_password: string;
|
ntfy_password_set: boolean;
|
||||||
ntfy_enabled: boolean;
|
ntfy_enabled: boolean;
|
||||||
rss_enabled: boolean;
|
rss_enabled: boolean;
|
||||||
rss_token: string | null;
|
rss_token: string | null;
|
||||||
@@ -212,6 +212,11 @@ export interface NotificationSettings {
|
|||||||
alert_filters: Record<string, Record<string, boolean | string[]>> | null;
|
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 {
|
export interface Collection {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user