130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
"use client";
|
|
import { useEffect, useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Eye, EyeOff, Gem, Star, Package } from "lucide-react";
|
|
import { setWatched, setCoverVip, setCoverFavorite, setCoverOwned } from "@/app/actions/coverMeta";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Kind = "watched" | "vip" | "favorite" | "owned";
|
|
|
|
const CONFIG: Record<Kind, {
|
|
onLabel: string;
|
|
offLabel: string;
|
|
OnIcon: React.ComponentType<{ className?: string }>;
|
|
OffIcon: React.ComponentType<{ className?: string }>;
|
|
onClass: string;
|
|
offClass: string;
|
|
action: (id: number, on: boolean) => Promise<void>;
|
|
}> = {
|
|
watched: {
|
|
onLabel: "Watched",
|
|
offLabel: "Not Watched",
|
|
OnIcon: Eye,
|
|
OffIcon: EyeOff,
|
|
onClass: "bg-[var(--color-mint)]/10 border-[var(--color-mint)]/30 text-[var(--color-mint)] hover:bg-[var(--color-mint)]/20",
|
|
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:border-[var(--color-glass-border-strong)]",
|
|
action: setWatched,
|
|
},
|
|
vip: {
|
|
onLabel: "VIP",
|
|
offLabel: "VIP",
|
|
OnIcon: Gem,
|
|
OffIcon: Gem,
|
|
onClass: "bg-cyan-400/15 border-cyan-400/40 text-cyan-200 hover:bg-cyan-400/25",
|
|
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-cyan-200 hover:border-cyan-400/40",
|
|
action: setCoverVip,
|
|
},
|
|
favorite: {
|
|
onLabel: "Favorite",
|
|
offLabel: "Favorite",
|
|
OnIcon: Star,
|
|
OffIcon: Star,
|
|
onClass: "bg-amber-400/15 border-amber-400/40 text-amber-200 hover:bg-amber-400/25",
|
|
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-amber-200 hover:border-amber-400/40",
|
|
action: setCoverFavorite,
|
|
},
|
|
owned: {
|
|
onLabel: "Owned",
|
|
offLabel: "Owned",
|
|
OnIcon: Package,
|
|
OffIcon: Package,
|
|
onClass: "bg-[var(--color-violet)]/15 border-[var(--color-violet)]/40 text-[var(--color-violet)] hover:bg-[var(--color-violet)]/25",
|
|
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-[var(--color-violet)] hover:border-[var(--color-violet)]/40",
|
|
action: setCoverOwned,
|
|
},
|
|
};
|
|
|
|
// Custom event that lets siblings predict the server-side mutual
|
|
// exclusion between VIP and Favorite (one being set on clears the
|
|
// other). Without this, the just-cleared pill would show as "on"
|
|
// optimistically until router.refresh() round-trips the new initial
|
|
// prop. The event lets the affected sibling clear its local state
|
|
// immediately on the same render tick.
|
|
const MUTEX_EVENT = "pinkudex:cover-flag-mutex";
|
|
interface MutexDetail { imageId: number; clearedKind: Kind }
|
|
|
|
export function CoverFlagToggle({
|
|
kind,
|
|
imageId,
|
|
initial,
|
|
}: {
|
|
kind: Kind;
|
|
imageId: number;
|
|
initial: boolean;
|
|
}) {
|
|
const router = useRouter();
|
|
const cfg = CONFIG[kind];
|
|
const [on, setLocal] = useState(initial);
|
|
const [, start] = useTransition();
|
|
// Sync to fresh server state — needed so VIP and Favorite stay mutually exclusive
|
|
// when the other one is toggled and the page refreshes.
|
|
useEffect(() => { setLocal(initial); }, [initial]);
|
|
|
|
// Listen for sibling toggles that would mutex-clear our flag.
|
|
useEffect(() => {
|
|
if (kind !== "vip" && kind !== "favorite") return;
|
|
const handler = (ev: Event) => {
|
|
const d = (ev as CustomEvent<MutexDetail>).detail;
|
|
if (d && d.imageId === imageId && d.clearedKind === kind) {
|
|
setLocal(false);
|
|
}
|
|
};
|
|
window.addEventListener(MUTEX_EVENT, handler);
|
|
return () => window.removeEventListener(MUTEX_EVENT, handler);
|
|
}, [imageId, kind]);
|
|
|
|
const Icon = on ? cfg.OnIcon : cfg.OffIcon;
|
|
|
|
function toggle() {
|
|
const next = !on;
|
|
setLocal(next);
|
|
// Server clears the opposite flag when VIP/Favorite is turned on.
|
|
// Tell our sibling instance now so its UI doesn't lag the action.
|
|
if (next && (kind === "vip" || kind === "favorite")) {
|
|
const cleared: Kind = kind === "vip" ? "favorite" : "vip";
|
|
window.dispatchEvent(new CustomEvent<MutexDetail>(MUTEX_EVENT, {
|
|
detail: { imageId, clearedKind: cleared },
|
|
}));
|
|
}
|
|
start(async () => {
|
|
await cfg.action(imageId, next);
|
|
router.refresh();
|
|
});
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={toggle}
|
|
title={`Toggle ${cfg.onLabel}`}
|
|
className={cn(
|
|
"flex w-full min-w-0 items-center justify-center gap-1.5 px-2 py-1.5 rounded-full text-[10px] uppercase tracking-wider font-mono border transition-colors cursor-pointer",
|
|
on ? cfg.onClass : cfg.offClass,
|
|
)}
|
|
>
|
|
<Icon className={cn("w-3 h-3", kind === "favorite" && on && "fill-amber-200")} />
|
|
{on ? cfg.onLabel : cfg.offLabel}
|
|
</button>
|
|
);
|
|
}
|