Stacks were a name and a coloured dot. The dot carried the status but nothing
carried identity, so a list of twenty stacks read as twenty identical rows.
This gives each one an icon in front of its name and moves the status onto that
icon as a halo in the status colour, which is the thing the eye lands on anyway.
The constraint that shaped the design: people already have stacks. Asking them
to pick an icon for each one before the feature does anything would mean it
never gets used, so the icon is *derived* from the stack's name and the column
stays empty until somebody overrides it. ~700 keywords in 79 groups cover the
self-hosted long tail (jellyfin -> clapperboard, vaultwarden -> key,
home-assistant -> house) plus generic English and German terms; the longest
match wins, so photoprism beats a bare photo, and short keywords like "tv" only
match as whole words. No backfill, no migration, and a rename moves the icon
with it.
That is also why the catalog and the matcher live in the frontend. It is the
only place that can render an icon, so a copy in the backend would be a list to
keep in sync and nothing else. The server validates the shape of the stored
value and stores uploads; it never needs to know what "lucide:database" looks
like. An icon name that later leaves the catalog falls back to the derived one
rather than blanking the row.
Overriding happens in two places, because there are two moments: the editor
(holding a chosen file until the stack exists, since uploading needs an id) and
a click on the icon on the detail page, which is how a stack that has existed
for a year gets one without a trip through the editor.
Uploads are classified by their bytes, not by the filename or Content-Type the
browser claims, and land in ${DATA_DIR}/stack-icons/ under the stack id. SVG is
allowed — <img> does not execute it — but the endpoint serves every icon as an
attachment so one can never be opened as a document in the API's own origin. A
client-supplied "custom:" value is refused: the server mints those, so a stack
cannot be pointed at a file it does not own. Files follow the stack: replaced on
re-upload (including across formats, or the old one orphans), copied on clone,
removed on delete.
The one piece of plumbing worth knowing about: the icon endpoint needs the
bearer token like everything else, and an <img src> would not carry it. So
StackIcon fetches the bytes through the API client and renders the blob, keyed
on the stored value — which carries an upload timestamp precisely so a re-upload
changes the key and retires the cached image.
Covered by 22 backend tests (the value rules, byte-sniffing, the file lifecycle,
the API round-trip, and that the read-only role cannot change an icon) and 29
frontend ones for the matcher. The schema change was verified against a
hand-built pre-0.51 database: the column is added on start and existing rows
come back NULL, i.e. automatic. Not click-tested in a browser — no Docker in
this environment — so the row height the taller icon produces is unverified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
8.4 KiB
TypeScript
238 lines
8.4 KiB
TypeScript
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<HTMLInputElement>(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(
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
onClick={() => !busy && onClose()}
|
|
>
|
|
<div
|
|
className="flex max-h-full w-full max-w-lg flex-col overflow-hidden rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-700">
|
|
<StackIcon stack={stack} status={status} size="lg" previewUrl={previewUrl} />
|
|
<div className="min-w-0 flex-1">
|
|
<h2 className="sp-heading text-lg">Stack icon</h2>
|
|
<p className="truncate text-sm text-slate-500">{stack.name || "New stack"}</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
disabled={busy}
|
|
title="Close"
|
|
className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 p-4 dark:border-slate-700">
|
|
<Button
|
|
variant={isAutomatic ? "primary" : "outline"}
|
|
onClick={() => onSelect("")}
|
|
disabled={busy}
|
|
title={`Derived from the stack name (currently “${automatic}”)`}
|
|
>
|
|
<Sparkles className="h-4 w-4" /> Automatic
|
|
</Button>
|
|
<Button variant="outline" onClick={() => fileInput.current?.click()} disabled={busy}>
|
|
<Upload className="h-4 w-4" /> Upload image…
|
|
</Button>
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
accept={ACCEPTED}
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
pickFile(e.target.files?.[0]);
|
|
// Reset, or picking the same file twice fires no change event.
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<div className="relative min-w-[140px] flex-1">
|
|
<Input
|
|
placeholder="Search icons…"
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
|
{groups.length === 0 && (
|
|
<p className="text-sm text-slate-500">No icon matches “{q}”.</p>
|
|
)}
|
|
{groups.map((group) => (
|
|
<section key={group.label} className="mb-4 last:mb-0">
|
|
<h3 className="sp-label mb-2">{group.label}</h3>
|
|
<div className="grid grid-cols-8 gap-1.5 sm:grid-cols-10">
|
|
{Object.entries(group.icons).map(([name, Glyph]) => {
|
|
const selected = stack.icon === `lucide:${name}`;
|
|
return (
|
|
<button
|
|
key={name}
|
|
title={name}
|
|
disabled={busy}
|
|
onClick={() => onSelect(`lucide:${name}`)}
|
|
className={cn(
|
|
"flex aspect-square items-center justify-center rounded-lg border transition-colors",
|
|
"disabled:opacity-40",
|
|
selected
|
|
? "border-accent bg-accent/10 text-accent dark:text-accent-dark"
|
|
: "border-transparent text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700",
|
|
// The automatic pick is called out so it is obvious what
|
|
// "Automatic" would give you.
|
|
!selected &&
|
|
isAutomatic &&
|
|
name === automatic &&
|
|
"border-slate-300 dark:border-slate-600"
|
|
)}
|
|
>
|
|
<Glyph className="h-[18px] w-[18px]" />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
|
|
<p className="border-t border-slate-200 px-4 py-2.5 text-xs text-slate-500 dark:border-slate-700">
|
|
PNG, JPEG, GIF, WebP or SVG, up to 512 KiB. Square images look best —
|
|
anything else is cropped to fit.
|
|
</p>
|
|
</div>
|
|
</div>,
|
|
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<unknown>) => {
|
|
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 <StackIcon stack={stack} status={status} size={size} />;
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
title="Change icon"
|
|
className="rounded-[15px] outline-none ring-offset-2 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-accent dark:ring-offset-slate-900"
|
|
>
|
|
<StackIcon stack={stack} status={status} size={size} />
|
|
</button>
|
|
{open && (
|
|
<IconPicker
|
|
stack={stack}
|
|
status={status}
|
|
busy={busy}
|
|
onSelect={(icon) => apply(stacksApi.update(stack.id, { icon }))}
|
|
onUpload={(file) => apply(stacksApi.uploadIcon(stack.id, file))}
|
|
onClose={() => setOpen(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|