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
189 lines
6.3 KiB
TypeScript
189 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useEffect, useState } from "react";
|
|
import { Heart, Shield, Zap, ChevronDown } from "lucide-react";
|
|
import { useAddFollow, useIsFollowing, useRemoveFollow, useUpdateFollowMode } from "@/lib/hooks/useFollows";
|
|
import { useAuthStore } from "@/stores/authStore";
|
|
import { AuthModal } from "./AuthModal";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const MODES = {
|
|
neutral: {
|
|
label: "Following",
|
|
icon: Heart,
|
|
color: "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400",
|
|
},
|
|
pocket_veto: {
|
|
label: "Pocket Veto",
|
|
icon: Shield,
|
|
color: "bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400",
|
|
},
|
|
pocket_boost: {
|
|
label: "Pocket Boost",
|
|
icon: Zap,
|
|
color: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400",
|
|
},
|
|
} as const;
|
|
|
|
type FollowMode = keyof typeof MODES;
|
|
|
|
interface FollowButtonProps {
|
|
type: "bill" | "member" | "topic";
|
|
value: string;
|
|
label?: string;
|
|
supportsModes?: boolean;
|
|
}
|
|
|
|
export function FollowButton({ type, value, label, supportsModes = false }: FollowButtonProps) {
|
|
const existing = useIsFollowing(type, value);
|
|
const add = useAddFollow();
|
|
const remove = useRemoveFollow();
|
|
const updateMode = useUpdateFollowMode();
|
|
const token = useAuthStore((s) => s.token);
|
|
const [open, setOpen] = useState(false);
|
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
function requireAuth(action: () => void) {
|
|
if (!token) { setShowAuthModal(true); return; }
|
|
action();
|
|
}
|
|
|
|
const isFollowing = !!existing;
|
|
const currentMode: FollowMode = (existing?.follow_mode as FollowMode) ?? "neutral";
|
|
const isPending = add.isPending || remove.isPending || updateMode.isPending;
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
}, [open]);
|
|
|
|
// Simple toggle for non-bill follows
|
|
if (!supportsModes) {
|
|
const handleClick = () => {
|
|
requireAuth(() => {
|
|
if (isFollowing && existing) {
|
|
remove.mutate(existing.id);
|
|
} else {
|
|
add.mutate({ type, value });
|
|
}
|
|
});
|
|
};
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={handleClick}
|
|
disabled={isPending}
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
|
|
isFollowing
|
|
? "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400"
|
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
)}
|
|
>
|
|
<Heart className={cn("w-3.5 h-3.5", isFollowing && "fill-current")} />
|
|
{isFollowing ? "Unfollow" : label || "Follow"}
|
|
</button>
|
|
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Mode-aware follow button for bills
|
|
if (!isFollowing) {
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => requireAuth(() => add.mutate({ type, value }))}
|
|
disabled={isPending}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<Heart className="w-3.5 h-3.5" />
|
|
{label || "Follow"}
|
|
</button>
|
|
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
const { label: modeLabel, icon: ModeIcon, color } = MODES[currentMode];
|
|
const otherModes = (Object.keys(MODES) as FollowMode[]).filter((m) => m !== currentMode);
|
|
|
|
const switchMode = (mode: FollowMode) => {
|
|
requireAuth(() => {
|
|
if (existing) updateMode.mutate({ id: existing.id, mode });
|
|
setOpen(false);
|
|
});
|
|
};
|
|
|
|
const handleUnfollow = () => {
|
|
requireAuth(() => {
|
|
if (existing) remove.mutate(existing.id);
|
|
setOpen(false);
|
|
});
|
|
};
|
|
|
|
const modeDescriptions: Record<FollowMode, string> = {
|
|
neutral: "Alert me on all material changes",
|
|
pocket_veto: "Alert me only if this bill advances toward passage",
|
|
pocket_boost: "Alert me on all changes + remind me to contact my rep",
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => setOpen((v) => !v)}
|
|
disabled={isPending}
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
|
|
color
|
|
)}
|
|
>
|
|
<ModeIcon className={cn("w-3.5 h-3.5", currentMode === "neutral" && "fill-current")} />
|
|
{modeLabel}
|
|
<ChevronDown className="w-3 h-3 ml-0.5 opacity-70" />
|
|
</button>
|
|
|
|
{open && (
|
|
<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) => {
|
|
const { label: optLabel, icon: OptIcon } = MODES[mode];
|
|
return (
|
|
<button
|
|
key={mode}
|
|
onClick={() => switchMode(mode)}
|
|
title={modeDescriptions[mode]}
|
|
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">
|
|
<OptIcon className="w-3.5 h-3.5" />
|
|
Switch to {optLabel}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground pl-5">{modeDescriptions[mode]}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
<div className="border-t border-border mt-1 pt-1">
|
|
<button
|
|
onClick={handleUnfollow}
|
|
className="w-full text-left px-3 py-2 text-sm bg-card hover:bg-accent text-destructive transition-colors"
|
|
>
|
|
Unfollow
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
|
|
</>
|
|
);
|
|
}
|