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:
@@ -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)}`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user