Add follow toggles to tag chips on detail views

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.
This commit is contained in:
2026-09-17 14:12:46 -05:00
parent 03dd235f3a
commit 4585a8ac43
4 changed files with 93 additions and 1 deletions
+18
View File
@@ -1,4 +1,5 @@
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { TagFollowToggle } from "@/features/follows/TagFollowToggle";
const TAG_STYLES: Record<string, string> = { const TAG_STYLES: Record<string, string> = {
general: general:
@@ -26,11 +27,13 @@ export function TagChip({
category, category,
count, count,
onClick, onClick,
followable = false,
}: { }: {
tag: string; tag: string;
category?: string; category?: string;
count?: number; count?: number;
onClick?: () => void; onClick?: () => void;
followable?: boolean;
}) { }) {
const style = TAG_STYLES[category ?? "unknown"] ?? TAG_STYLES.unknown; const style = TAG_STYLES[category ?? "unknown"] ?? TAG_STYLES.unknown;
const className = cn( const className = cn(
@@ -39,6 +42,21 @@ export function TagChip({
); );
const label = count !== undefined ? `${tag} (${count})` : tag; const label = count !== undefined ? `${tag} (${count})` : tag;
if (followable) {
return (
<span className={cn(className, "inline-flex items-center gap-1")}>
{onClick ? (
<button type="button" onClick={onClick} className="text-left">
{label}
</button>
) : (
<span>{label}</span>
)}
<TagFollowToggle tag={tag} />
</span>
);
}
if (!onClick) { if (!onClick) {
return <span className={className}>{label}</span>; return <span className={className}>{label}</span>;
} }
@@ -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<string | null>(null);
const query = useQuery({
queryKey: ["follows-tags"],
queryFn: () => api<Paginated<FollowedTag>>("/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<FollowedTag>("/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<HTMLButtonElement>) {
// 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 (
<button
type="button"
onClick={handleClick}
disabled={mutation.isPending || query.isPending}
title={error ?? (followed ? `Unfollow ${tag}` : `Follow ${tag}`)}
aria-label={followed ? `Unfollow ${tag}` : `Follow ${tag}`}
className={cn(
"-mr-0.5 inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm transition",
error ? "text-ctp-red" : "opacity-70 hover:opacity-100",
mutation.isPending && "opacity-40",
)}
>
{followed ? (
<Check className="h-3 w-3" strokeWidth={3} />
) : (
<Plus className="h-3 w-3" strokeWidth={3} />
)}
</button>
);
}
@@ -307,7 +307,7 @@ function E621MetadataCard({ item }: { item: MediaItem }) {
</p> </p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{tags.map((tag) => ( {tags.map((tag) => (
<TagChip key={tag} tag={tag} category={category} /> <TagChip key={tag} tag={tag} category={category} followable />
))} ))}
</div> </div>
</div> </div>
@@ -403,6 +403,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
key={tag} key={tag}
tag={tag} tag={tag}
category={category} category={category}
followable
onClick={() => onClick={() =>
navigate(`/online?tags=${encodeURIComponent(tag)}`) navigate(`/online?tags=${encodeURIComponent(tag)}`)
} }