import { useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useQueryClient } from "@tanstack/react-query"; import { Sparkles, Upload, X } from "lucide-react"; import { toast } from "sonner"; import { Button, Input } from "@/components/ui"; import { StackIcon } from "@/components/ui/StackIcon"; import { cn } from "@/lib/utils"; import { ICON_GROUPS, suggestIconName } from "@/lib/stackIcons"; import { stacksApi } from "@/api/stacks"; import { apiErrorMessage } from "@/api/client"; import type { StackStatus } from "@/types"; /** Matches services/icon_service.py — the server rejects anything larger. */ const MAX_ICON_BYTES = 512 * 1024; const ACCEPTED = "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"; /** * Pick a stack's icon: keep the automatic one, choose a built-in, or upload an * image. * * The dialog itself is stateless — it reports the choice and lets the caller * decide what to do with it, because the two callers differ: the editor holds * the choice until the stack is saved (a stack being created has no id to * upload to yet), while the detail page applies it immediately. */ export function IconPicker({ stack, status = "stopped", previewUrl, busy = false, onSelect, onUpload, onClose, }: { stack: { id: string; name: string; icon?: string | null }; status?: StackStatus; previewUrl?: string | null; busy?: boolean; /** "" means "back to the icon derived from the name". */ onSelect: (icon: string) => void; onUpload: (file: File) => void; onClose: () => void; }) { const [q, setQ] = useState(""); const fileInput = useRef(null); const automatic = useMemo( () => suggestIconName(stack.name, stack.id), [stack.name, stack.id] ); const isAutomatic = !stack.icon; const groups = useMemo(() => { const needle = q.trim().toLowerCase(); if (!needle) return ICON_GROUPS; return ICON_GROUPS.map((group) => ({ label: group.label, icons: Object.fromEntries( Object.entries(group.icons).filter(([name]) => name.includes(needle)) ), })).filter((group) => Object.keys(group.icons).length > 0); }, [q]); const pickFile = (file: File | undefined) => { if (!file) return; if (file.size > MAX_ICON_BYTES) { toast.error(`That image is ${Math.round(file.size / 1024)} KiB; the limit is 512 KiB.`); return; } onUpload(file); }; return createPortal(
!busy && onClose()} >
e.stopPropagation()} >

Stack icon

{stack.name || "New stack"}

{ pickFile(e.target.files?.[0]); // Reset, or picking the same file twice fires no change event. e.target.value = ""; }} />
setQ(e.target.value)} />
{groups.length === 0 && (

No icon matches “{q}”.

)} {groups.map((group) => (

{group.label}

{Object.entries(group.icons).map(([name, Glyph]) => { const selected = stack.icon === `lucide:${name}`; return ( ); })}
))}

PNG, JPEG, GIF, WebP or SVG, up to 512 KiB. Square images look best — anything else is cropped to fit.

, document.body ); } /** * The stack's icon, clickable: opens the picker and applies the choice right * away. Used on the stack detail page, so an existing stack can be given an * icon without going through the editor. */ export function StackIconEditor({ stack, status, size = "lg", editable = true, }: { stack: { id: string; name: string; icon?: string | null }; status: StackStatus; size?: "sm" | "md" | "lg"; /** The read-only role sees the icon but cannot change it. */ editable?: boolean; }) { const qc = useQueryClient(); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const apply = async (call: Promise) => { setBusy(true); try { await call; qc.invalidateQueries({ queryKey: ["stack", stack.id] }); qc.invalidateQueries({ queryKey: ["stacks"] }); setOpen(false); } catch (err) { toast.error(apiErrorMessage(err)); } finally { setBusy(false); } }; if (!editable) return ; return ( <> {open && ( apply(stacksApi.update(stack.id, { icon }))} onUpload={(file) => apply(stacksApi.uploadIcon(stack.id, file))} onClose={() => setOpen(false)} /> )} ); }