Merge branch 'public_page'
Authored-By: Jack Levy
This commit is contained in:
@@ -6,7 +6,7 @@ from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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.models import Bill, BillBrief, Follow, TrendScore
|
||||
from app.models.user import User
|
||||
@@ -15,11 +15,38 @@ from app.schemas.schemas import BillSchema
|
||||
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("")
|
||||
async def get_dashboard(
|
||||
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
|
||||
follows_result = await db.execute(
|
||||
select(Follow).where(Follow.user_id == current_user.id)
|
||||
@@ -79,28 +106,9 @@ async def get_dashboard(
|
||||
# Sort feed by latest action date
|
||||
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 {
|
||||
"feed": [serialize_bill(b) for b in feed_bills[:50]],
|
||||
"trending": [serialize_bill(b) for b in trending_bills],
|
||||
"feed": [_serialize_bill(b) for b in feed_bills[:50]],
|
||||
"trending": trending,
|
||||
"follows": {
|
||||
"bills": len(followed_bill_ids),
|
||||
"members": len(followed_member_ids),
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -30,6 +31,19 @@ async def get_current_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(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
|
||||
@@ -2,11 +2,37 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Search } from "lucide-react";
|
||||
import { useMembers } from "@/lib/hooks/useMembers";
|
||||
import { Search, Heart } from "lucide-react";
|
||||
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 { 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() {
|
||||
const [q, setQ] = useState("");
|
||||
const [chamber, setChamber] = useState("");
|
||||
@@ -18,6 +44,10 @@ export default function MembersPage() {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -51,6 +81,21 @@ export default function MembersPage() {
|
||||
</select>
|
||||
</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 ? (
|
||||
<div className="text-center py-20 text-muted-foreground">Loading members...</div>
|
||||
) : (
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"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 { BillCard } from "@/components/shared/BillCard";
|
||||
import { adminAPI } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isLoading, refetch } = useDashboard();
|
||||
const [polling, setPolling] = useState(false);
|
||||
const token = useAuthStore((s) => s.token);
|
||||
|
||||
const triggerPoll = async () => {
|
||||
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="md:col-span-2 space-y-4">
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Your Feed
|
||||
{data?.follows && (
|
||||
{token ? <BookOpen className="w-4 h-4" /> : <Flame className="w-4 h-4" />}
|
||||
{token ? "Your Feed" : "Most Popular"}
|
||||
{token && data?.follows && (
|
||||
<span className="text-xs text-muted-foreground font-normal">
|
||||
({data.follows.bills} bills · {data.follows.members} members · {data.follows.topics} topics)
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useAuthStore } from "@/stores/authStore";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
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 }) {
|
||||
const router = useRouter();
|
||||
@@ -22,22 +23,24 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
if (!token && !PUBLIC_PATHS.includes(pathname)) {
|
||||
const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
|
||||
if (!token && needsAuth) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [hydrated, token, pathname, router]);
|
||||
|
||||
if (!hydrated) return null;
|
||||
|
||||
// Public pages (login/register) render without the app shell
|
||||
if (PUBLIC_PATHS.includes(pathname)) {
|
||||
// Login/register pages render without the app shell
|
||||
if (NO_SHELL_PATHS.includes(pathname)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Not logged in yet — blank while redirecting
|
||||
if (!token) return null;
|
||||
// Auth-required pages: blank while redirecting
|
||||
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 (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Desktop sidebar — hidden on mobile */}
|
||||
|
||||
39
frontend/components/shared/AuthModal.tsx
Normal file
39
frontend/components/shared/AuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
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 = {
|
||||
@@ -37,9 +39,16 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
|
||||
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;
|
||||
@@ -59,13 +68,16 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
|
||||
// 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}
|
||||
@@ -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")} />
|
||||
{isFollowing ? "Unfollow" : label || "Follow"}
|
||||
</button>
|
||||
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Mode-aware follow button for bills
|
||||
if (!isFollowing) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => add.mutate({ type, value })}
|
||||
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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 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> = {
|
||||
@@ -116,6 +137,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
@@ -160,5 +182,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,13 +20,13 @@ import { ThemeToggle } from "./ThemeToggle";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard, adminOnly: false },
|
||||
{ href: "/bills", label: "Bills", icon: FileText, adminOnly: false },
|
||||
{ href: "/members", label: "Members", icon: Users, adminOnly: false },
|
||||
{ href: "/topics", label: "Topics", icon: Tags, adminOnly: false },
|
||||
{ href: "/following", label: "Following", icon: Heart, adminOnly: false },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false },
|
||||
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true },
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard, adminOnly: false, requiresAuth: false },
|
||||
{ href: "/bills", label: "Bills", icon: FileText, adminOnly: false, requiresAuth: false },
|
||||
{ href: "/members", label: "Members", icon: Users, adminOnly: false, requiresAuth: false },
|
||||
{ href: "/topics", label: "Topics", icon: Tags, adminOnly: false, requiresAuth: false },
|
||||
{ href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false, requiresAuth: true },
|
||||
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false },
|
||||
];
|
||||
|
||||
export function Sidebar({ onClose }: { onClose?: () => void }) {
|
||||
@@ -34,6 +34,7 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
function handleLogout() {
|
||||
@@ -55,7 +56,11 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
|
||||
</div>
|
||||
|
||||
<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);
|
||||
return (
|
||||
<Link
|
||||
@@ -77,6 +82,8 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-border space-y-2">
|
||||
{token ? (
|
||||
<>
|
||||
{user && (
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
</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">
|
||||
<span className="text-xs text-muted-foreground">Theme</span>
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { followsAPI } from "../api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
export function useFollows() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
return useQuery({
|
||||
queryKey: ["follows"],
|
||||
queryFn: () => followsAPI.list(),
|
||||
staleTime: 30 * 1000,
|
||||
enabled: !!token,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +17,10 @@ export function useAddFollow() {
|
||||
return useMutation({
|
||||
mutationFn: ({ type, value }: { type: string; value: string }) =>
|
||||
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();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => followsAPI.remove(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["follows"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user