import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, ChevronRight, Eye, StarOff } from "lucide-react"; import { useState } from "react"; import { Link } from "react-router-dom"; import { Button, EmptyState, Spinner } from "@/components/ui"; import { api } from "@/lib/api"; import { cn } from "@/lib/cn"; import { formatDate } from "@/lib/format"; import type { FollowCloud, FollowCoverData, FollowedPool, FollowedTag, FollowFeed, } from "@/lib/types"; const ratingPill: Record = { s: "bg-ctp-green text-ctp-crust", q: "bg-ctp-peach text-ctp-crust", e: "bg-ctp-red text-ctp-crust", }; function invalidateFollows(queryClient: ReturnType) { void queryClient.invalidateQueries({ queryKey: ["follows-tags"] }); void queryClient.invalidateQueries({ queryKey: ["follows-pools"] }); void queryClient.invalidateQueries({ queryKey: ["follows-feed"] }); } function unseenLabel(count: number): string { return count > 99 ? "99+ unseen" : `${count} unseen`; } function FollowCard({ title, meta, cover, unseen, syncedAt, selected, onSelect, onSeen, onUnfollow, }: { title: string; meta: string; cover: FollowCoverData | null; unseen: number; syncedAt: string | null; selected: boolean; onSelect: () => void; onSeen: () => void; onUnfollow: () => void; }) { const metaLine = [meta, syncedAt ? `synced ${formatDate(syncedAt)}` : null] .filter(Boolean) .join(" · "); return (

{title}

{metaLine ? (

{metaLine}

) : null}
); } function BlacklistCloudPanel() { const query = useQuery({ queryKey: ["follows-cloud"], queryFn: () => api("/api/follows/cloud/"), refetchInterval: (q) => q.state.data?.status === "building" ? 2000 : false, }); const cloud = query.data; const [expanded, setExpanded] = useState(false); return (
{expanded ? ( <>

{cloud?.source === "user" ? "From your e621 blacklist" : "From e621's anonymous default blacklist"} {cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""} {cloud?.computed_at ? ` · computed ${formatDate(cloud.computed_at)}` : ""}

{query.isPending ? (
) : cloud && cloud.tags.length > 0 ? (
{cloud.tags.map(([tag, count]) => ( {tag} ({count}) ))}
) : (

No blacklisted tags in your feeds.

)} ) : null}
); } export default function FollowedPage() { const queryClient = useQueryClient(); const [feedSource, setFeedSource] = useState<{ kind: "all" | "tag" | "pool"; id?: number; label?: string; }>({ kind: "all" }); const [unseenOnly, setUnseenOnly] = useState(false); const [page, setPage] = useState(1); const tagsQuery = useQuery({ queryKey: ["follows-tags"], // Unpaginated: this is the complete follow list, shown as cards below. queryFn: () => api("/api/follows/tags/"), }); const poolsQuery = useQuery({ queryKey: ["follows-pools"], queryFn: () => api("/api/follows/pools/"), }); const feedQuery = useQuery({ queryKey: [ "follows-feed", feedSource.kind, feedSource.id ?? null, unseenOnly, page, ], queryFn: () => { const params = new URLSearchParams({ page: String(page), per_page: "48", }); if (feedSource.kind === "tag") params.set("tag", String(feedSource.id)); if (feedSource.kind === "pool") params.set("pool", String(feedSource.id)); if (unseenOnly) params.set("unseen", "1"); return api(`/api/follows/feed/?${params.toString()}`); }, }); const seenFollow = useMutation({ mutationFn: ({ kind, id }: { kind: "tag" | "pool"; id: number }) => api(`/api/follows/${kind === "tag" ? "tags" : "pools"}/${id}/seen/`, { method: "POST", }), onSuccess: () => invalidateFollows(queryClient), }); const seenPost = useMutation({ mutationFn: (postId: number) => api(`/api/follows/posts/${postId}/seen/`, { method: "POST" }), onSuccess: () => invalidateFollows(queryClient), }); const unfollow = useMutation({ mutationFn: ({ kind, id }: { kind: "tag" | "pool"; id: number }) => api(`/api/follows/${kind === "tag" ? "tags" : "pools"}/${id}/`, { method: "DELETE", }), onSuccess: () => invalidateFollows(queryClient), }); function selectSource(source: { kind: "all" | "tag" | "pool"; id?: number; label?: string; }) { setPage(1); setFeedSource((current) => current.kind === source.kind && current.id === source.id ? { kind: "all" } : source, ); } const tags = tagsQuery.data ?? []; const pools = poolsQuery.data ?? []; const feed = feedQuery.data; return (

Followed

New posts from your followed e621 tags and pools. Follow tags with the + on any tag chip in the online or library detail views, and pools from a pool page. The periodic commands refresh these feeds and their covers.

Tags{" "} {tags.length}

{tagsQuery.isPending ? (
) : tags.length === 0 ? (

No followed tags yet — use the + on any tag chip.

) : (
{tags.map((follow) => ( selectSource({ kind: "tag", id: follow.id, label: `tag:${follow.tag}`, }) } onSeen={() => seenFollow.mutate({ kind: "tag", id: follow.id })} onUnfollow={() => unfollow.mutate({ kind: "tag", id: follow.id })} /> ))}
)}

Pools{" "} {pools.length}

{poolsQuery.isPending ? (
) : pools.length === 0 ? (

No followed pools yet — open a pool and use "Follow pool".

) : (
{pools.map((follow) => ( selectSource({ kind: "pool", id: follow.id, label: follow.name || `Pool #${follow.pool_id}`, }) } onSeen={() => seenFollow.mutate({ kind: "pool", id: follow.id }) } onUnfollow={() => unfollow.mutate({ kind: "pool", id: follow.id }) } /> ))}
)}

Feed{" "} {feed ? `${feed.count}` : "…"} {feedSource.kind !== "all" ? ` · showing ${feedSource.label}` : ""}

{feedSource.kind !== "all" ? ( ) : null}
{feedQuery.isPending ? (
) : !feed || feed.results.length === 0 ? ( ) : (
{feed.results.map((entry) => ( { if (!entry.seen) seenPost.mutate(entry.post_id); }} className="group block overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2" > {entry.data.preview?.url ? ( {`Post ) : ( No preview )} {!entry.seen ? ( ) : null} {entry.data.rating ? ( {entry.data.rating} ) : null} #{entry.post_id} {" "} ▲ {entry.data.score?.total ?? 0} {entry.data.fav_count ? ` · ${entry.data.fav_count} ★` : ""} {entry.sources.map((source) => source.label).join(", ")} ))}
)} {feed && (page > 1 || feed.has_next) ? (
Page {page}
) : null}
); }