Merge branch 'public_page'

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 15:55:02 -05:00
9 changed files with 314 additions and 128 deletions

View File

@@ -6,7 +6,7 @@ from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.dependencies import get_current_user from app.core.dependencies import get_optional_user
from app.database import get_db from app.database import get_db
from app.models import Bill, BillBrief, Follow, TrendScore from app.models import Bill, BillBrief, Follow, TrendScore
from app.models.user import User from app.models.user import User
@@ -15,11 +15,38 @@ from app.schemas.schemas import BillSchema
router = APIRouter() router = APIRouter()
async def _get_trending(db: AsyncSession) -> list[dict]:
trending_result = await db.execute(
select(Bill)
.options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores))
.join(TrendScore, Bill.bill_id == TrendScore.bill_id)
.where(TrendScore.score_date >= date.today() - timedelta(days=1))
.order_by(desc(TrendScore.composite_score))
.limit(10)
)
trending_bills = trending_result.scalars().unique().all()
return [_serialize_bill(b) for b in trending_bills]
def _serialize_bill(bill: Bill) -> dict:
b = BillSchema.model_validate(bill)
if bill.briefs:
b.latest_brief = bill.briefs[0]
if bill.trend_scores:
b.latest_trend = bill.trend_scores[0]
return b.model_dump()
@router.get("") @router.get("")
async def get_dashboard( async def get_dashboard(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User | None = Depends(get_optional_user),
): ):
trending = await _get_trending(db)
if current_user is None:
return {"feed": [], "trending": trending, "follows": {"bills": 0, "members": 0, "topics": 0}}
# Load follows for the current user # Load follows for the current user
follows_result = await db.execute( follows_result = await db.execute(
select(Follow).where(Follow.user_id == current_user.id) select(Follow).where(Follow.user_id == current_user.id)
@@ -79,28 +106,9 @@ async def get_dashboard(
# Sort feed by latest action date # Sort feed by latest action date
feed_bills.sort(key=lambda b: b.latest_action_date or date.min, reverse=True) feed_bills.sort(key=lambda b: b.latest_action_date or date.min, reverse=True)
# 4. Trending bills (top 10 by composite score today)
trending_result = await db.execute(
select(Bill)
.options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores))
.join(TrendScore, Bill.bill_id == TrendScore.bill_id)
.where(TrendScore.score_date >= date.today() - timedelta(days=1))
.order_by(desc(TrendScore.composite_score))
.limit(10)
)
trending_bills = trending_result.scalars().unique().all()
def serialize_bill(bill: Bill) -> dict:
b = BillSchema.model_validate(bill)
if bill.briefs:
b.latest_brief = bill.briefs[0]
if bill.trend_scores:
b.latest_trend = bill.trend_scores[0]
return b.model_dump()
return { return {
"feed": [serialize_bill(b) for b in feed_bills[:50]], "feed": [_serialize_bill(b) for b in feed_bills[:50]],
"trending": [serialize_bill(b) for b in trending_bills], "trending": trending,
"follows": { "follows": {
"bills": len(followed_bill_ids), "bills": len(followed_bill_ids),
"members": len(followed_member_ids), "members": len(followed_member_ids),

View File

@@ -8,6 +8,7 @@ from app.database import get_db
from app.models.user import User from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_current_user( async def get_current_user(
@@ -30,6 +31,19 @@ async def get_current_user(
return user return user
async def get_optional_user(
token: str | None = Depends(oauth2_scheme_optional),
db: AsyncSession = Depends(get_db),
) -> User | None:
if not token:
return None
try:
user_id = decode_token(token)
return await db.get(User, user_id)
except Exception:
return None
async def get_current_admin( async def get_current_admin(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> User: ) -> User:

View File

@@ -2,11 +2,37 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Search } from "lucide-react"; import { Search, Heart } from "lucide-react";
import { useMembers } from "@/lib/hooks/useMembers"; import { useMembers, useMember } from "@/lib/hooks/useMembers";
import { useFollows } from "@/lib/hooks/useFollows";
import { useAuthStore } from "@/stores/authStore";
import { FollowButton } from "@/components/shared/FollowButton"; import { FollowButton } from "@/components/shared/FollowButton";
import { cn, partyBadgeColor } from "@/lib/utils"; import { cn, partyBadgeColor } from "@/lib/utils";
function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
const { data: member } = useMember(bioguideId);
if (!member) return null;
return (
<div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors">
{member.name}
</Link>
<div className="flex items-center gap-1.5 mt-1">
{member.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
</div>
);
}
export default function MembersPage() { export default function MembersPage() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [chamber, setChamber] = useState(""); const [chamber, setChamber] = useState("");
@@ -18,6 +44,10 @@ export default function MembersPage() {
page, per_page: 50, page, per_page: 50,
}); });
const token = useAuthStore((s) => s.token);
const { data: follows } = useFollows();
const followedMemberIds = follows?.filter((f) => f.follow_type === "member").map((f) => f.follow_value) ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -51,6 +81,21 @@ export default function MembersPage() {
</select> </select>
</div> </div>
{token && followedMemberIds.length > 0 && (
<div className="space-y-3">
<h2 className="font-semibold text-sm flex items-center gap-2">
<Heart className="w-4 h-4 text-red-500 fill-red-500" />
Following ({followedMemberIds.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{followedMemberIds.map((id) => (
<FollowedMemberRow key={id} bioguideId={id} />
))}
</div>
<div className="border-t border-border pt-2" />
</div>
)}
{isLoading ? ( {isLoading ? (
<div className="text-center py-20 text-muted-foreground">Loading members...</div> <div className="text-center py-20 text-muted-foreground">Loading members...</div>
) : ( ) : (

View File

@@ -1,14 +1,17 @@
"use client"; "use client";
import { TrendingUp, BookOpen, RefreshCw } from "lucide-react"; import { TrendingUp, BookOpen, Flame, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useDashboard } from "@/lib/hooks/useDashboard"; import { useDashboard } from "@/lib/hooks/useDashboard";
import { BillCard } from "@/components/shared/BillCard"; import { BillCard } from "@/components/shared/BillCard";
import { adminAPI } from "@/lib/api"; import { adminAPI } from "@/lib/api";
import { useState } from "react"; import { useState } from "react";
import { useAuthStore } from "@/stores/authStore";
export default function DashboardPage() { export default function DashboardPage() {
const { data, isLoading, refetch } = useDashboard(); const { data, isLoading, refetch } = useDashboard();
const [polling, setPolling] = useState(false); const [polling, setPolling] = useState(false);
const token = useAuthStore((s) => s.token);
const triggerPoll = async () => { const triggerPoll = async () => {
setPolling(true); setPolling(true);
@@ -43,15 +46,38 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-8">
<div className="md:col-span-2 space-y-4"> <div className="md:col-span-2 space-y-4">
<h2 className="font-semibold flex items-center gap-2"> <h2 className="font-semibold flex items-center gap-2">
<BookOpen className="w-4 h-4" /> {token ? <BookOpen className="w-4 h-4" /> : <Flame className="w-4 h-4" />}
Your Feed {token ? "Your Feed" : "Most Popular"}
{data?.follows && ( {token && data?.follows && (
<span className="text-xs text-muted-foreground font-normal"> <span className="text-xs text-muted-foreground font-normal">
({data.follows.bills} bills · {data.follows.members} members · {data.follows.topics} topics) ({data.follows.bills} bills · {data.follows.members} members · {data.follows.topics} topics)
</span> </span>
)} )}
</h2> </h2>
{!data?.feed?.length ? ( {!token ? (
<div className="space-y-3">
<div className="rounded-lg border border-dashed px-4 py-3 flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
Sign in to personalise this feed with bills and members you follow.
</p>
<div className="flex gap-2 shrink-0">
<Link href="/register" className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
Register
</Link>
<Link href="/login" className="px-3 py-1.5 text-xs font-medium rounded-md border border-border text-foreground hover:bg-accent transition-colors">
Sign in
</Link>
</div>
</div>
{data?.trending?.length ? (
<div className="space-y-3">
{data.trending.map((bill) => (
<BillCard key={bill.bill_id} bill={bill} />
))}
</div>
) : null}
</div>
) : !data?.feed?.length ? (
<div className="bg-card border border-border rounded-lg p-8 text-center text-muted-foreground"> <div className="bg-card border border-border rounded-lg p-8 text-center text-muted-foreground">
<p className="text-sm">Your feed is empty.</p> <p className="text-sm">Your feed is empty.</p>
<p className="text-xs mt-1">Follow bills, members, or topics to see activity here.</p> <p className="text-xs mt-1">Follow bills, members, or topics to see activity here.</p>

View File

@@ -6,7 +6,8 @@ import { useAuthStore } from "@/stores/authStore";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { MobileHeader } from "./MobileHeader"; import { MobileHeader } from "./MobileHeader";
const PUBLIC_PATHS = ["/login", "/register"]; const NO_SHELL_PATHS = ["/login", "/register"];
const AUTH_REQUIRED = ["/following", "/notifications"];
export function AuthGuard({ children }: { children: React.ReactNode }) { export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
@@ -22,22 +23,24 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
if (!hydrated) return; if (!hydrated) return;
if (!token && !PUBLIC_PATHS.includes(pathname)) { const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
if (!token && needsAuth) {
router.replace("/login"); router.replace("/login");
} }
}, [hydrated, token, pathname, router]); }, [hydrated, token, pathname, router]);
if (!hydrated) return null; if (!hydrated) return null;
// Public pages (login/register) render without the app shell // Login/register pages render without the app shell
if (PUBLIC_PATHS.includes(pathname)) { if (NO_SHELL_PATHS.includes(pathname)) {
return <>{children}</>; return <>{children}</>;
} }
// Not logged in yet — blank while redirecting // Auth-required pages: blank while redirecting
if (!token) return null; const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
if (!token && needsAuth) return null;
// Authenticated: render the full app shell // Authenticated or guest browsing: render the full app shell
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
{/* Desktop sidebar — hidden on mobile */} {/* Desktop sidebar — hidden on mobile */}

View File

@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import * as Dialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
interface AuthModalProps {
open: boolean;
onClose: () => void;
}
export function AuthModal({ open, onClose }: AuthModalProps) {
return (
<Dialog.Root open={open} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-sm bg-card border border-border rounded-lg shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
<Dialog.Title className="text-base font-semibold">
Sign in to follow bills
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-muted-foreground">
Create a free account to follow bills, set Pocket Veto or Pocket Boost modes, and receive alerts.
</Dialog.Description>
<div className="flex gap-3 mt-4">
<Link href="/register" onClick={onClose} className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
Create account
</Link>
<Link href="/login" onClick={onClose} className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-md border border-border text-foreground hover:bg-accent transition-colors">
Sign in
</Link>
</div>
<Dialog.Close className="absolute right-4 top-4 p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors">
<X className="w-4 h-4" />
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -3,6 +3,8 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Heart, Shield, Zap, ChevronDown } from "lucide-react"; import { Heart, Shield, Zap, ChevronDown } from "lucide-react";
import { useAddFollow, useIsFollowing, useRemoveFollow, useUpdateFollowMode } from "@/lib/hooks/useFollows"; import { useAddFollow, useIsFollowing, useRemoveFollow, useUpdateFollowMode } from "@/lib/hooks/useFollows";
import { useAuthStore } from "@/stores/authStore";
import { AuthModal } from "./AuthModal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MODES = { const MODES = {
@@ -37,9 +39,16 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
const add = useAddFollow(); const add = useAddFollow();
const remove = useRemoveFollow(); const remove = useRemoveFollow();
const updateMode = useUpdateFollowMode(); const updateMode = useUpdateFollowMode();
const token = useAuthStore((s) => s.token);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
function requireAuth(action: () => void) {
if (!token) { setShowAuthModal(true); return; }
action();
}
const isFollowing = !!existing; const isFollowing = !!existing;
const currentMode: FollowMode = (existing?.follow_mode as FollowMode) ?? "neutral"; const currentMode: FollowMode = (existing?.follow_mode as FollowMode) ?? "neutral";
const isPending = add.isPending || remove.isPending || updateMode.isPending; const isPending = add.isPending || remove.isPending || updateMode.isPending;
@@ -59,13 +68,16 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
// Simple toggle for non-bill follows // Simple toggle for non-bill follows
if (!supportsModes) { if (!supportsModes) {
const handleClick = () => { const handleClick = () => {
requireAuth(() => {
if (isFollowing && existing) { if (isFollowing && existing) {
remove.mutate(existing.id); remove.mutate(existing.id);
} else { } else {
add.mutate({ type, value }); add.mutate({ type, value });
} }
});
}; };
return ( return (
<>
<button <button
onClick={handleClick} onClick={handleClick}
disabled={isPending} disabled={isPending}
@@ -79,20 +91,25 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
<Heart className={cn("w-3.5 h-3.5", isFollowing && "fill-current")} /> <Heart className={cn("w-3.5 h-3.5", isFollowing && "fill-current")} />
{isFollowing ? "Unfollow" : label || "Follow"} {isFollowing ? "Unfollow" : label || "Follow"}
</button> </button>
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
</>
); );
} }
// Mode-aware follow button for bills // Mode-aware follow button for bills
if (!isFollowing) { if (!isFollowing) {
return ( return (
<>
<button <button
onClick={() => add.mutate({ type, value })} onClick={() => requireAuth(() => add.mutate({ type, value }))}
disabled={isPending} 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" 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" /> <Heart className="w-3.5 h-3.5" />
{label || "Follow"} {label || "Follow"}
</button> </button>
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
</>
); );
} }
@@ -100,13 +117,17 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
const otherModes = (Object.keys(MODES) as FollowMode[]).filter((m) => m !== currentMode); const otherModes = (Object.keys(MODES) as FollowMode[]).filter((m) => m !== currentMode);
const switchMode = (mode: FollowMode) => { const switchMode = (mode: FollowMode) => {
requireAuth(() => {
if (existing) updateMode.mutate({ id: existing.id, mode }); if (existing) updateMode.mutate({ id: existing.id, mode });
setOpen(false); setOpen(false);
});
}; };
const handleUnfollow = () => { const handleUnfollow = () => {
requireAuth(() => {
if (existing) remove.mutate(existing.id); if (existing) remove.mutate(existing.id);
setOpen(false); setOpen(false);
});
}; };
const modeDescriptions: Record<FollowMode, string> = { const modeDescriptions: Record<FollowMode, string> = {
@@ -116,6 +137,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
}; };
return ( return (
<>
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
@@ -160,5 +182,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
</div> </div>
)} )}
</div> </div>
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
</>
); );
} }

View File

@@ -20,13 +20,13 @@ import { ThemeToggle } from "./ThemeToggle";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
const NAV = [ const NAV = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard, adminOnly: false }, { href: "/", label: "Dashboard", icon: LayoutDashboard, adminOnly: false, requiresAuth: false },
{ href: "/bills", label: "Bills", icon: FileText, adminOnly: false }, { href: "/bills", label: "Bills", icon: FileText, adminOnly: false, requiresAuth: false },
{ href: "/members", label: "Members", icon: Users, adminOnly: false }, { href: "/members", label: "Members", icon: Users, adminOnly: false, requiresAuth: false },
{ href: "/topics", label: "Topics", icon: Tags, adminOnly: false }, { href: "/topics", label: "Topics", icon: Tags, adminOnly: false, requiresAuth: false },
{ href: "/following", label: "Following", icon: Heart, adminOnly: false }, { href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true },
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false }, { href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false, requiresAuth: true },
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true }, { href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false },
]; ];
export function Sidebar({ onClose }: { onClose?: () => void }) { export function Sidebar({ onClose }: { onClose?: () => void }) {
@@ -34,6 +34,7 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
const router = useRouter(); const router = useRouter();
const qc = useQueryClient(); const qc = useQueryClient();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const token = useAuthStore((s) => s.token);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
function handleLogout() { function handleLogout() {
@@ -55,7 +56,11 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
</div> </div>
<nav className="flex-1 p-3 space-y-1"> <nav className="flex-1 p-3 space-y-1">
{NAV.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ href, label, icon: Icon }) => { {NAV.filter(({ adminOnly, requiresAuth }) => {
if (adminOnly && !user?.is_admin) return false;
if (requiresAuth && !token) return false;
return true;
}).map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return ( return (
<Link <Link
@@ -77,6 +82,8 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
</nav> </nav>
<div className="p-3 border-t border-border space-y-2"> <div className="p-3 border-t border-border space-y-2">
{token ? (
<>
{user && ( {user && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<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}>
@@ -91,6 +98,17 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
</button> </button>
</div> </div>
)} )}
</>
) : (
<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">
Register
</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">
Sign in
</Link>
</div>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Theme</span> <span className="text-xs text-muted-foreground">Theme</span>
<ThemeToggle /> <ThemeToggle />

View File

@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { followsAPI } from "../api"; import { followsAPI } from "../api";
import { useAuthStore } from "@/stores/authStore";
export function useFollows() { export function useFollows() {
const token = useAuthStore((s) => s.token);
return useQuery({ return useQuery({
queryKey: ["follows"], queryKey: ["follows"],
queryFn: () => followsAPI.list(), queryFn: () => followsAPI.list(),
staleTime: 30 * 1000, staleTime: 30 * 1000,
enabled: !!token,
}); });
} }
@@ -14,7 +17,10 @@ export function useAddFollow() {
return useMutation({ return useMutation({
mutationFn: ({ type, value }: { type: string; value: string }) => mutationFn: ({ type, value }: { type: string; value: string }) =>
followsAPI.add(type, value), followsAPI.add(type, value),
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }), onSuccess: () => {
qc.invalidateQueries({ queryKey: ["follows"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
}); });
} }
@@ -22,7 +28,10 @@ export function useRemoveFollow() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: number) => followsAPI.remove(id), mutationFn: (id: number) => followsAPI.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }), onSuccess: () => {
qc.invalidateQueries({ queryKey: ["follows"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
}); });
} }