feat: PocketVeto v1.0.0 — initial public release

Self-hosted US Congress monitoring platform with AI policy briefs,
bill/member/topic follows, ntfy + RSS + email notifications,
alignment scoring, collections, and draft-letter generator.

Authored by: Jack Levy
This commit is contained in:
Jack Levy
2026-03-15 01:35:01 -04:00
commit 4c86a5b9ca
150 changed files with 19859 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Pin, PinOff, Trash2, Save } from "lucide-react";
import { notesAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
interface NotesPanelProps {
billId: string;
}
export function NotesPanel({ billId }: NotesPanelProps) {
const token = useAuthStore((s) => s.token);
const qc = useQueryClient();
const queryKey = ["note", billId];
const { data: note, isLoading } = useQuery({
queryKey,
queryFn: () => notesAPI.get(billId),
enabled: !!token,
retry: false, // 404 = no note; don't retry
throwOnError: false,
});
const [content, setContent] = useState("");
const [pinned, setPinned] = useState(false);
const [saved, setSaved] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Sync form from loaded note
useEffect(() => {
if (note) {
setContent(note.content);
setPinned(note.pinned);
}
}, [note]);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [content]);
const upsert = useMutation({
mutationFn: () => notesAPI.upsert(billId, content, pinned),
onSuccess: (updated) => {
qc.setQueryData(queryKey, updated);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const remove = useMutation({
mutationFn: () => notesAPI.delete(billId),
onSuccess: () => {
qc.removeQueries({ queryKey });
setContent("");
setPinned(false);
},
});
if (!token) return (
<div className="bg-card border border-border rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground">Sign in to add private notes.</p>
</div>
);
if (isLoading) return null;
const hasNote = !!note;
const isDirty = hasNote
? content !== note.content || pinned !== note.pinned
: content.trim().length > 0;
return (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a private note about this bill…"
rows={3}
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary resize-none overflow-hidden"
/>
<div className="flex items-center justify-between gap-3">
{/* Left: pin toggle + delete */}
<div className="flex items-center gap-3">
<button
onClick={() => setPinned((v) => !v)}
title={pinned ? "Unpin note" : "Pin above tabs"}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
pinned
? "border-primary text-primary bg-primary/10"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{pinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
{pinned ? "Pinned" : "Pin"}
</button>
{hasNote && (
<button
onClick={() => remove.mutate()}
disabled={remove.isPending}
title="Delete note"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Right: save */}
<button
onClick={() => upsert.mutate()}
disabled={!content.trim() || upsert.isPending || (!isDirty && !saved)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Save className="w-3 h-3" />
{saved ? "Saved!" : upsert.isPending ? "Saving…" : "Save"}
</button>
</div>
<p className="text-xs text-muted-foreground">Private only visible to you.</p>
</div>
);
}