diff --git a/frontend/src/components/TagChip.tsx b/frontend/src/components/TagChip.tsx index 0f56746..e7aa611 100644 --- a/frontend/src/components/TagChip.tsx +++ b/frontend/src/components/TagChip.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/cn"; +import { TagFollowToggle } from "@/features/follows/TagFollowToggle"; const TAG_STYLES: Record = { general: @@ -26,11 +27,13 @@ export function TagChip({ category, count, onClick, + followable = false, }: { tag: string; category?: string; count?: number; onClick?: () => void; + followable?: boolean; }) { const style = TAG_STYLES[category ?? "unknown"] ?? TAG_STYLES.unknown; const className = cn( @@ -39,6 +42,21 @@ export function TagChip({ ); const label = count !== undefined ? `${tag} (${count})` : tag; + if (followable) { + return ( + + {onClick ? ( + + ) : ( + {label} + )} + + + ); + } + if (!onClick) { return {label}; } diff --git a/frontend/src/features/follows/TagFollowToggle.tsx b/frontend/src/features/follows/TagFollowToggle.tsx new file mode 100644 index 0000000..d4248ca --- /dev/null +++ b/frontend/src/features/follows/TagFollowToggle.tsx @@ -0,0 +1,73 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Plus } from "lucide-react"; +import { useState, type MouseEvent } from "react"; + +import { api, errorMessage } from "@/lib/api"; +import { cn } from "@/lib/cn"; +import type { FollowedTag, Paginated } from "@/lib/types"; +import { useAuth } from "@/store/auth"; + +export function TagFollowToggle({ tag }: { tag: string }) { + const user = useAuth((state) => state.user); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + + const query = useQuery({ + queryKey: ["follows-tags"], + queryFn: () => api>("/api/follows/tags/"), + enabled: Boolean(user), + staleTime: 30_000, + }); + + const normalized = tag.trim().toLowerCase(); + const follow = query.data?.results.find((entry) => entry.tag === normalized); + const followed = Boolean(follow); + + const mutation = useMutation({ + mutationFn: () => + follow + ? api(`/api/follows/tags/${follow.id}/`, { method: "DELETE" }) + : api("/api/follows/tags/", { + method: "POST", + json: { tag: normalized }, + }), + onSuccess: () => { + setError(null); + void queryClient.invalidateQueries({ queryKey: ["follows-tags"] }); + void queryClient.invalidateQueries({ queryKey: ["follows-feed"] }); + void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] }); + }, + onError: (err) => setError(errorMessage(err)), + }); + + if (!user) return null; + + function handleClick(event: MouseEvent) { + // Chips often sit inside clickable labels/links; the toggle is its own + // action and must not trigger them. + event.preventDefault(); + event.stopPropagation(); + mutation.mutate(); + } + + return ( + + ); +} diff --git a/frontend/src/features/library/LibraryDetail.tsx b/frontend/src/features/library/LibraryDetail.tsx index 91a3ee8..82d816f 100644 --- a/frontend/src/features/library/LibraryDetail.tsx +++ b/frontend/src/features/library/LibraryDetail.tsx @@ -307,7 +307,7 @@ function E621MetadataCard({ item }: { item: MediaItem }) {

{tags.map((tag) => ( - + ))}
diff --git a/frontend/src/features/online/OnlineDetail.tsx b/frontend/src/features/online/OnlineDetail.tsx index df791e0..26e07c9 100644 --- a/frontend/src/features/online/OnlineDetail.tsx +++ b/frontend/src/features/online/OnlineDetail.tsx @@ -403,6 +403,7 @@ export function OnlineDetail({ postId }: { postId: number }) { key={tag} tag={tag} category={category} + followable onClick={() => navigate(`/online?tags=${encodeURIComponent(tag)}`) }