import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { cn } from "@/lib/utils"; import { iconComponent, imageIconKey, resolveStackIcon, suggestIconName, } from "@/lib/stackIcons"; import type { IconStack } from "@/lib/stackIcons"; import { stacksApi } from "@/api/stacks"; import type { StackStatus } from "@/types"; /** * A stack's icon, wearing its status as a coloured glow. * * This replaces the status dot in the stacks list: the same information, but * carried by the thing the eye lands on anyway. The status is still spelled out * in the badge next to it and in the tooltip here, so the colour is never the * only way to read it. * * The icon is either an image the server holds — the app's real logo, or an * upload — or a glyph derived from the name. Images are fetched through the API * client because that endpoint needs the bearer token. */ type Size = "sm" | "md" | "lg"; const SIZES: Record = { sm: { box: "h-7 w-7", glyph: "h-3.5 w-3.5", radius: "rounded-[9px]" }, md: { box: "h-9 w-9", glyph: "h-[18px] w-[18px]", radius: "rounded-[11px]" }, lg: { box: "h-12 w-12", glyph: "h-6 w-6", radius: "rounded-[15px]" }, }; /** Ring + halo per status. Kept as whole class strings because Tailwind only * sees classes it can find literally in the source. */ const STATUS_STYLE: Record = { running: { ring: "ring-green-500/60 dark:ring-green-400/60", halo: "bg-green-500/40 dark:bg-green-400/40", glyph: "text-green-600 dark:text-green-400", }, partial: { ring: "ring-yellow-500/60 dark:ring-yellow-400/60", halo: "bg-yellow-500/40 dark:bg-yellow-400/40", glyph: "text-yellow-600 dark:text-yellow-400", }, stopped: { ring: "ring-slate-400/50 dark:ring-slate-500/50", halo: "bg-slate-400/20 dark:bg-slate-500/20", glyph: "text-slate-500 dark:text-slate-400", }, error: { ring: "ring-red-500/60 dark:ring-red-400/60", halo: "bg-red-500/45 dark:bg-red-400/45", glyph: "text-red-600 dark:text-red-400", }, updating: { ring: "ring-sky-500/60 dark:ring-sky-400/60", halo: "bg-sky-500/45 dark:bg-sky-400/45", glyph: "text-sky-600 dark:text-sky-400", }, unknown: { ring: "ring-slate-300/60 dark:ring-slate-600/60", halo: "bg-slate-300/20 dark:bg-slate-600/20", glyph: "text-slate-400 dark:text-slate-500", }, }; export function StackIcon({ stack, status, size = "md", previewUrl, className, }: { stack: IconStack; status: StackStatus; size?: Size; /** Shows this image instead of the stored icon — used to preview a file that * has been chosen but not uploaded yet (a stack being created). */ previewUrl?: string | null; className?: string; }) { const resolved = resolveStackIcon(stack); const dims = SIZES[size]; const tone = STATUS_STYLE[status] ?? STATUS_STYLE.unknown; const stored = useStackImageUrl( stack.id, resolved.kind === "image" && !previewUrl ? imageIconKey(stack) : null ); const custom = previewUrl ?? stored; // The glyph doubles as the fallback for an image that cannot be fetched (a // logo the server has not got yet), so derive it from the name either way // rather than landing on the generic mark. const Glyph = iconComponent( resolved.kind === "builtin" ? resolved.name : suggestIconName(stack.name, stack.id) ); return ( {/* The status "shimmer": a blurred copy of the status colour bleeding out from behind the tile. Pulses while an operation is running. */} {custom ? ( // Logos are drawn for a light ground and many are dark line art, so // the tile stays light in both themes rather than swallowing them. // `contain`, not `cover`: a logo must not be cropped. ) : ( )} ); } /** * Object URL for a stack's image icon, or null while there is none. * * The icon endpoint needs the bearer token, so the bytes are fetched through * the API client and handed to the browser as a blob. `icon` is part of the * query key: an upload carries a version and a logo carries its slug, so * changing either retires the previous image instead of leaving a stale one on * screen. `retry: false` matters here — a stack whose logo the server cannot * fetch (no internet yet) must fall through to its glyph quietly. */ function useStackImageUrl(stackId: string, icon: string | null | undefined): string | null { const { data: blob } = useQuery({ queryKey: ["stack-icon", stackId, icon], queryFn: () => stacksApi.icon(stackId), enabled: Boolean(icon), staleTime: Infinity, gcTime: 60 * 60 * 1000, retry: false, }); const [url, setUrl] = useState(null); useEffect(() => { if (!blob) { setUrl(null); return; } const objectUrl = URL.createObjectURL(blob); setUrl(objectUrl); // Every mount makes its own URL; without this each row would leak one for // the lifetime of the tab. return () => URL.revokeObjectURL(objectUrl); }, [blob]); return url; }