From 4585a8ac43fbb0c442bc8def6ca9219c9039e115 Mon Sep 17 00:00:00 2001
From: JakeBreath
Date: Thu, 17 Sep 2026 14:12:46 -0500
Subject: [PATCH] Add follow toggles to tag chips on detail views
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
TagChip gains a followable mode that renders a small toggle inside the
chip: '+' follows the tag, '✓' (click to unfollow) once followed, and a
red marker with the reason as its title when e621 rejects it. Enabled on
the library detail's e621 metadata card (matched posts) and on online
post detail tags; guests see plain chips and the label click keeps
working beside the toggle.
---
frontend/src/components/TagChip.tsx | 18 +++++
.../src/features/follows/TagFollowToggle.tsx | 73 +++++++++++++++++++
.../src/features/library/LibraryDetail.tsx | 2 +-
frontend/src/features/online/OnlineDetail.tsx | 1 +
4 files changed, 93 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/features/follows/TagFollowToggle.tsx
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)}`)
}