diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 00c8c99..a9893f7 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -2,7 +2,7 @@ from datetime import date, timedelta from fastapi import Depends from fastapi import APIRouter -from sqlalchemy import desc, select +from sqlalchemy import desc, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -92,15 +92,15 @@ async def get_dashboard( feed_bills.append(bill) seen_ids.add(bill.bill_id) - # 3. Bills matching followed topics - for topic in followed_topics: + # 3. Bills matching followed topics (single query with OR across all topics) + if followed_topics: result = await db.execute( select(Bill) .options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores)) .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)) - .limit(10) + .limit(20) ) for bill in result.scalars().all(): if bill.bill_id not in seen_ids: diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 4a82963..272f90a 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -12,6 +12,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db 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_token=prefs.get("ntfy_token", ""), 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), rss_enabled=prefs.get("rss_enabled", False), rss_token=rss_token, @@ -88,7 +89,7 @@ async def update_notification_settings( if body.ntfy_username is not None: prefs["ntfy_username"] = body.ntfy_username.strip() 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: prefs["ntfy_enabled"] = body.ntfy_enabled if body.rss_enabled is not None: diff --git a/backend/app/api/search.py b/backend/app/api/search.py index abdb4be..903e566 100644 --- a/backend/app/api/search.py +++ b/backend/app/api/search.py @@ -11,7 +11,7 @@ router = APIRouter() @router.get("") async def search( - q: str = Query(..., min_length=2), + q: str = Query(..., min_length=2, max_length=500), db: AsyncSession = Depends(get_db), ): # Bill ID direct match diff --git a/backend/app/config.py b/backend/app/config.py index 5ff2bf7..ace682a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ from functools import lru_cache +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -13,6 +14,11 @@ class Settings(BaseSettings): JWT_SECRET_KEY: str = "change-me-in-production" 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_URL: str = "postgresql+asyncpg://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" # 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. - # Raise this in .env once you confirm your API tier. - LLM_RATE_LIMIT_RPM: int = 10 + # Defaults: free Gemini=15 RPM, Anthropic paid=50 RPM, OpenAI paid=500 RPM. + # Lower this in .env if you hit rate limit errors on a restricted tier. + LLM_RATE_LIMIT_RPM: int = 50 # Google Civic Information API (zip → representative lookup) # Free key: https://console.cloud.google.com/apis/library/civicinfo.googleapis.com @@ -54,6 +60,15 @@ class Settings(BaseSettings): # pytrends 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_HOST: str = "" SMTP_PORT: int = 587 diff --git a/backend/app/core/crypto.py b/backend/app/core/crypto.py new file mode 100644 index 0000000..b5fa673 --- /dev/null +++ b/backend/app/core/crypto.py @@ -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() diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 0071141..14650e3 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -40,7 +40,7 @@ async def get_optional_user( try: user_id = decode_token(token) return await db.get(User, user_id) - except Exception: + except (JWTError, ValueError): return None diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 3799d92..39a2c56 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -1,7 +1,7 @@ from datetime import date, datetime from typing import Any, Generic, Optional, TypeVar -from pydantic import BaseModel +from pydantic import BaseModel, field_validator # ── Notifications ────────────────────────────────────────────────────────────── @@ -31,7 +31,7 @@ class NotificationSettingsResponse(BaseModel): ntfy_auth_method: str = "none" # none | token | basic ntfy_token: str = "" ntfy_username: str = "" - ntfy_password: str = "" + ntfy_password_set: bool = False ntfy_enabled: bool = False rss_enabled: bool = False rss_token: Optional[str] = None @@ -315,11 +315,13 @@ class CollectionCreate(BaseModel): name: str is_public: bool = False - def validate_name(self) -> str: - name = self.name.strip() - if not 1 <= len(name) <= 100: + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + v = v.strip() + if not 1 <= len(v) <= 100: raise ValueError("name must be 1–100 characters") - return name + return v class CollectionUpdate(BaseModel): diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py index de9d7dc..aca8858 100644 --- a/backend/app/workers/notification_dispatcher.py +++ b/backend/app/workers/notification_dispatcher.py @@ -14,6 +14,7 @@ from datetime import datetime, timedelta, timezone import requests +from app.core.crypto import decrypt_secret from app.database import get_sync_db from app.models.follow import Follow from app.models.notification import NotificationEvent @@ -162,7 +163,7 @@ def dispatch_notifications(self): ntfy_auth_method = prefs.get("ntfy_auth_method", "none") ntfy_token = prefs.get("ntfy_token", "").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) rss_enabled = prefs.get("rss_enabled", False) digest_enabled = prefs.get("digest_enabled", False) diff --git a/backend/app/workers/notification_utils.py b/backend/app/workers/notification_utils.py index 9e5779a..e83efe0 100644 --- a/backend/app/workers/notification_utils.py +++ b/backend/app/workers/notification_utils.py @@ -129,16 +129,20 @@ def emit_topic_follow_notifications( from app.models.follow import Follow 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() followers = [] follower_topic: dict[int, str] = {} - for tag in topic_tags: - for follow in db.query(Follow).filter_by(follow_type="topic", follow_value=tag).all(): - if follow.user_id not in seen_user_ids: - seen_user_ids.add(follow.user_id) - followers.append(follow) - follower_topic[follow.user_id] = tag + for follow in all_follows: + if follow.user_id not in seen_user_ids: + seen_user_ids.add(follow.user_id) + followers.append(follow) + follower_topic[follow.user_id] = follow.follow_value if not followers: return 0 diff --git a/frontend/app/bills/[id]/page.tsx b/frontend/app/bills/[id]/page.tsx index e5550a8..b3779a7 100644 --- a/frontend/app/bills/[id]/page.tsx +++ b/frontend/app/bills/[id]/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { use, useEffect, useRef } from "react"; +import { use, useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; 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 { useAuthStore } from "@/stores/authStore"; import { BriefPanel } from "@/components/bills/BriefPanel"; import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel"; import { NotesPanel } from "@/components/bills/NotesPanel"; @@ -15,28 +16,34 @@ import { NewsPanel } from "@/components/bills/NewsPanel"; import { FollowButton } from "@/components/shared/FollowButton"; import { CollectionPicker } from "@/components/bills/CollectionPicker"; import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils"; +import { TOPIC_LABEL, TOPIC_TAGS } from "@/lib/topics"; + +const TABS = [ + { id: "analysis", label: "Analysis" }, + { id: "timeline", label: "Timeline" }, + { id: "votes", label: "Votes" }, + { id: "notes", label: "Notes" }, +] as const; +type TabId = typeof TABS[number]["id"]; export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const billId = decodeURIComponent(id); + const [activeTab, setActiveTab] = useState("analysis"); + const token = useAuthStore((s) => s.token); const { data: bill, isLoading } = useBill(billId); const { data: trendData } = useBillTrend(billId, 30); const { data: newsArticles, refetch: refetchNews } = useBillNews(billId); - // Fetch the user's note so we know if it's pinned before rendering const { data: note } = useQuery({ queryKey: ["note", billId], queryFn: () => import("@/lib/api").then((m) => m.notesAPI.get(billId)), - enabled: true, + enabled: !!token, retry: 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); useEffect(() => { newsRetryRef.current = 0; }, [billId]); useEffect(() => { @@ -119,60 +126,103 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin {/* Content grid */}
-
- {/* Pinned note floats above briefs */} +
+ {/* Pinned note always visible above tabs */} {note?.pinned && } - {bill.briefs.length > 0 ? ( - <> - - - {!note?.pinned && } - - ) : bill.has_document ? ( - <> -
-

Analysis pending

-

- Bill text was retrieved but has not yet been analyzed. Check back shortly. -

-
- {!note?.pinned && } - - ) : ( - <> -
-
- - No bill text published -
-

- As of {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })},{" "} - no official text has been received for{" "} - {billLabel(bill.bill_type, bill.bill_number)}. - Analysis will be generated automatically once text is published on Congress.gov. -

- {bill.congress_url && ( - - Check status on Congress.gov - + {/* Tab bar */} +
+ {TABS.map((tab) => ( +
- {!note?.pinned && } - + > + {tab.label} + + ))} +
+ + {/* 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 && ( +
+ {bill.briefs[0].topic_tags.filter((t) => TOPIC_TAGS.has(t)).map((tag) => ( + + + {TOPIC_LABEL[tag] ?? tag} + + ))} +
+ )} + + {/* Tab content */} + {activeTab === "analysis" && ( +
+ {bill.briefs.length > 0 ? ( + <> + + + + ) : bill.has_document ? ( +
+

Analysis pending

+

+ Bill text was retrieved but has not yet been analyzed. Check back shortly. +

+
+ ) : ( +
+
+ + No bill text published +
+

+ As of {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })},{" "} + no official text has been received for{" "} + {billLabel(bill.bill_type, bill.bill_number)}. + Analysis will be generated automatically once text is published on Congress.gov. +

+ {bill.congress_url && ( + + Check status on Congress.gov + + )} +
+ )} +
+ )} + + {activeTab === "timeline" && ( + + )} + + {activeTab === "votes" && ( + + )} + + {activeTab === "notes" && ( + )} - -
+
diff --git a/frontend/app/bills/page.tsx b/frontend/app/bills/page.tsx index 9d85ebc..a37b74c 100644 --- a/frontend/app/bills/page.tsx +++ b/frontend/app/bills/page.tsx @@ -5,21 +5,16 @@ import { useSearchParams } from "next/navigation"; import { FileText, Search } from "lucide-react"; import { useBills } from "@/lib/hooks/useBills"; import { BillCard } from "@/components/shared/BillCard"; +import { TOPICS } from "@/lib/topics"; const CHAMBERS = ["", "House", "Senate"]; -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() { const searchParams = useSearchParams(); const [q, setQ] = useState(searchParams.get("q") ?? ""); const [chamber, setChamber] = useState(searchParams.get("chamber") ?? ""); const [topic, setTopic] = useState(searchParams.get("topic") ?? ""); - const [hasText, setHasText] = useState(false); + const [hasText, setHasText] = useState(true); const [page, setPage] = useState(1); // 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" > - {TOPICS.slice(1).map((t) => )} + {TOPICS.map((t) => )}

- Clicking a topic tag on any bill or following page takes you directly to that filtered - view on the Bills page. + Each bill page is organised into four tabs: Analysis (AI brief + draft + letter), Timeline (action history), Votes (roll-call + records), and Notes (your personal note). + Topic tags appear just below the tab bar — click any tag to jump to that filtered view.

+ + {/* Members & Topics */} +
+

+ Browse and follow legislators and policy topics independently of specific bills. +

+
+ + The Members 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 effectiveness score ranking how often their sponsored bills advance. + + + The Topics 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. + +
+
+ + {/* Dashboard */} +
+

+ The Dashboard is your + personalised home view, split into two areas. +

+
+ + Bills from your follows — directly followed bills, bills sponsored by followed members, + and bills matching followed topics — sorted by latest activity. + + + 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. + +
+
); } diff --git a/frontend/app/icon.svg b/frontend/app/icon.svg new file mode 100644 index 0000000..f238212 --- /dev/null +++ b/frontend/app/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx index e56b211..b405ed0 100644 --- a/frontend/app/notifications/page.tsx +++ b/frontend/app/notifications/page.tsx @@ -297,7 +297,7 @@ export default function NotificationsPage() { setAuthMethod(settings.ntfy_auth_method ?? "none"); setToken(settings.ntfy_token ?? ""); setUsername(settings.ntfy_username ?? ""); - setPassword(settings.ntfy_password ?? ""); + setPassword(""); // never pre-fill — password_set bool shows whether one is stored setEmailAddress(settings.email_address ?? ""); setEmailEnabled(settings.email_enabled ?? false); setDigestEnabled(settings.digest_enabled ?? false); @@ -333,7 +333,7 @@ export default function NotificationsPage() { ntfy_auth_method: authMethod, ntfy_token: authMethod === "token" ? token : "", ntfy_username: authMethod === "basic" ? username : "", - ntfy_password: authMethod === "basic" ? password : "", + ntfy_password: authMethod === "basic" ? (password || undefined) : "", ntfy_enabled: enabled, }, { onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } } @@ -565,7 +565,9 @@ export default function NotificationsPage() {
- 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" />
diff --git a/frontend/app/topics/page.tsx b/frontend/app/topics/page.tsx index 305d702..abb46c8 100644 --- a/frontend/app/topics/page.tsx +++ b/frontend/app/topics/page.tsx @@ -3,29 +3,7 @@ import Link from "next/link"; import { Tags } from "lucide-react"; import { FollowButton } from "@/components/shared/FollowButton"; - -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" }, -]; +import { TOPICS } from "@/lib/topics"; export default function TopicsPage() { return ( diff --git a/frontend/components/bills/AIBriefCard.tsx b/frontend/components/bills/AIBriefCard.tsx index 593b603..0a469ab 100644 --- a/frontend/components/bills/AIBriefCard.tsx +++ b/frontend/components/bills/AIBriefCard.tsx @@ -1,7 +1,7 @@ "use client"; 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 { formatDate } from "@/lib/utils"; @@ -30,28 +30,32 @@ function CitedItem({ point, icon, govinfo_url, openKey, activeKey, setActiveKey
  • {icon} - {cited ? point.text : point} - {cited && point.label === "inference" && ( - - Inferred - - )} - {cited && ( - - )} +
    +
    + {cited ? point.text : point} + {cited && point.label === "inference" && ( + + Inferred + + )} +
    + {cited && ( + + )} +
    {cited && isOpen && (
    @@ -165,19 +169,6 @@ export function AIBriefCard({ brief }: AIBriefCardProps) {
    )} - {brief.topic_tags && brief.topic_tags.length > 0 && ( -
    - - {brief.topic_tags.map((tag) => ( - - {tag} - - ))} -
    - )} ); } diff --git a/frontend/components/bills/NotesPanel.tsx b/frontend/components/bills/NotesPanel.tsx index 3cfc7a1..77e4c00 100644 --- a/frontend/components/bills/NotesPanel.tsx +++ b/frontend/components/bills/NotesPanel.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; 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 { useAuthStore } from "@/stores/authStore"; @@ -23,7 +23,6 @@ export function NotesPanel({ billId }: NotesPanelProps) { throwOnError: false, }); - const [open, setOpen] = useState(false); const [content, setContent] = useState(""); const [pinned, setPinned] = useState(false); const [saved, setSaved] = useState(false); @@ -43,7 +42,7 @@ export function NotesPanel({ billId }: NotesPanelProps) { if (!el) return; el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; - }, [content, open]); + }, [content]); const upsert = useMutation({ mutationFn: () => notesAPI.upsert(billId, content, pinned), @@ -60,12 +59,14 @@ export function NotesPanel({ billId }: NotesPanelProps) { qc.removeQueries({ queryKey }); setContent(""); setPinned(false); - setOpen(false); }, }); - // Don't render for guests - if (!token) return null; + if (!token) return ( +
    +

    Sign in to add private notes.

    +
    + ); if (isLoading) return null; const hasNote = !!note; @@ -74,78 +75,56 @@ export function NotesPanel({ billId }: NotesPanelProps) { : content.trim().length > 0; return ( -
    - {/* Header / toggle */} -