Manage server tags in Settings: pre-add tags, recolour, rename, delete
New Settings > Tags tab (admin) listing every tag -- ones servers use and ones added ahead of time -- with how many servers carry each: - Add a tag before anything uses it, optionally with a colour. Such tags are offered as one-click "Add:" chips (and datalist suggestions) when tagging a server, so the same word gets spelled the same way everywhere. - Give any tag a colour of your choosing, in use or not, or reset it to the automatic one. Changes are drafted with Save/Cancel rather than saved as the picker drags. Colours show on the Servers page, its tag filter bar, the detail page and the editor, and update everywhere without a reload through one shared cached colour map. - Rename a tag; every server that has it is rewritten. Renaming to a name that already exists merges the two after a confirmation naming what will happen; the target keeps its own colour unless it had none, and a server carrying both ends up with one. - Delete a tag, which removes it from every server that has it, with a confirmation stating how many. Tags still live on the servers (servers.tags); a new tag_definitions table holds only what a server can't: existence before use, and a colour. A defined tag stays listed until an admin deletes it, even with no servers. Rename and delete change the servers and the catalogue in one transaction so they can't disagree. Only servers that actually carry the tag are rewritten and counted -- an earlier draft also counted servers whose tags merely weren't in sorted order, which the tests caught. Reading the list and colours is open to everyone signed in (needed to draw tags anywhere); changing the catalogue is admin-only, while tagging a server stays an operator action. Names go through the same normalisation as before, colours must be #rrggbb, and every change is audit-logged. New table tag_definitions (migration 0013). Verified with 44 backend checks (list/counts, roles, create/adopt/ duplicate/rejects, colour set/reset, rename incl. defined, undefined and unused tags, merge colour rules and both-sides servers, delete, audit, and that normal tagging still works afterwards) and in a browser against the real routers: add with colour, set/reset a colour and see it change on the Servers page live, merge with confirmation, delete, and the error path. Not clicked through: the quick-add chips inside the tag editor on a server's detail page (typechecked; same colour code as the rest). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72f8c85406
commit
ae64cb345c
15 files changed
+2136
-25
No files matched your search
@@ -28,6 +28,7 @@ import Settings from "./pages/Settings";
|
||||
import NotificationSettings from "./pages/settings/NotificationSettings";
|
||||
import BadgeSettings from "./pages/settings/BadgeSettings";
|
||||
import DisplaySettings from "./pages/settings/DisplaySettings";
|
||||
import TagSettings from "./pages/settings/TagSettings";
|
||||
import CacheSettings from "./pages/settings/CacheSettings";
|
||||
import LogSettings from "./pages/settings/LogSettings";
|
||||
import BackupSettings from "./pages/settings/BackupSettings";
|
||||
@@ -146,6 +147,7 @@ export default function App() {
|
||||
<Route path="notifications" element={<NotificationSettings />} />
|
||||
<Route path="badges" element={<BadgeSettings />} />
|
||||
<Route path="display" element={<DisplaySettings />} />
|
||||
<Route path="tags" element={<TagSettings />} />
|
||||
<Route path="cache" element={<CacheSettings />} />
|
||||
<Route path="logs" element={<LogSettings />} />
|
||||
<Route path="backup" element={<BackupSettings />} />
|
||||
|
||||
@@ -570,6 +570,14 @@ export interface PrivacyOverview {
|
||||
};
|
||||
}
|
||||
|
||||
export interface TagEntry {
|
||||
name: string;
|
||||
/** "#rrggbb", or null for the automatic colour. */
|
||||
color: string | null;
|
||||
/** Servers currently carrying it. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DockhandContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -934,6 +942,17 @@ export const api = {
|
||||
remove: (id: number, portId: number) => request<void>(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }),
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
list: () => request<{ tags: TagEntry[] }>("/api/tags"),
|
||||
create: (name: string, color?: string) =>
|
||||
request<{ tags: TagEntry[] }>("/api/tags", { method: "POST", body: JSON.stringify({ name, color }) }),
|
||||
setColor: (name: string, color: string | null) =>
|
||||
request<{ tags: TagEntry[] }>("/api/tags/color", { method: "PUT", body: JSON.stringify({ name, color }) }),
|
||||
rename: (from: string, to: string) =>
|
||||
request<{ tags: TagEntry[]; updatedServers: number; merged: boolean }>("/api/tags/rename", { method: "POST", body: JSON.stringify({ from, to }) }),
|
||||
remove: (name: string) =>
|
||||
request<{ tags: TagEntry[]; updatedServers: number }>("/api/tags/delete", { method: "POST", body: JSON.stringify({ name }) }),
|
||||
},
|
||||
privacy: {
|
||||
overview: () => request<PrivacyOverview>("/api/privacy"),
|
||||
exportOwnData: async (): Promise<Blob> => {
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { normalizeTagInput, tagColorClass } from "../utils/tags";
|
||||
import { normalizeTagInput, tagBadge, useTagColors } from "../utils/tags";
|
||||
import { readableError } from "../utils/errors";
|
||||
|
||||
export function TagBadges({ tags, linked = false }: { tags: string[]; linked?: boolean }) {
|
||||
const colors = useTagColors();
|
||||
return (
|
||||
<>
|
||||
{tags.map((tag) =>
|
||||
linked ? (
|
||||
<Link key={tag} to={`/servers?tag=${encodeURIComponent(tag)}`} className={`badge text-decoration-none ${tagColorClass(tag)}`} title={`All servers tagged ${tag}`}>
|
||||
{tags.map((tag) => {
|
||||
const { className, style } = tagBadge(tag, colors);
|
||||
return linked ? (
|
||||
<Link key={tag} to={`/servers?tag=${encodeURIComponent(tag)}`} className={`${className} text-decoration-none`} style={style} title={`All servers tagged ${tag}`}>
|
||||
{tag}
|
||||
</Link>
|
||||
) : (
|
||||
<span key={tag} className={`badge ${tagColorClass(tag)}`}>
|
||||
<span key={tag} className={className} style={style}>
|
||||
{tag}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -40,16 +42,17 @@ export default function ServerTags({
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const colors = useTagColors();
|
||||
|
||||
function startEditing() {
|
||||
setDraftTags(tags);
|
||||
setInput("");
|
||||
setError(null);
|
||||
setEditing(true);
|
||||
// Offer the tags already used elsewhere, so the same word ends up spelled the same way everywhere.
|
||||
api.servers
|
||||
// Offer every tag that exists — pre-added ones and those already used — so the same word is spelled the same way everywhere.
|
||||
api.tags
|
||||
.list()
|
||||
.then((res) => setSuggestions([...new Set(res.servers.flatMap((s) => s.tags))].sort()))
|
||||
.then((res) => setSuggestions(res.tags.map((t) => t.name)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -99,18 +102,21 @@ export default function ServerTags({
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
<label className="form-label">Tags</label>
|
||||
<div className="d-flex flex-wrap align-items-center gap-1 mb-2">
|
||||
{draftTags.map((tag) => (
|
||||
<span key={tag} className={`badge ${tagColorClass(tag)}`}>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-sm ms-1"
|
||||
style={{ fontSize: "0.5rem" }}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={() => setDraftTags(draftTags.filter((t) => t !== tag))}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{draftTags.map((tag) => {
|
||||
const { className, style } = tagBadge(tag, colors);
|
||||
return (
|
||||
<span key={tag} className={className} style={style}>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-sm ms-1"
|
||||
style={{ fontSize: "0.5rem" }}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={() => setDraftTags(draftTags.filter((t) => t !== tag))}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{draftTags.length === 0 && <span className="text-secondary small">No tags yet.</span>}
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
@@ -145,6 +151,22 @@ export default function ServerTags({
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{suggestions.some((s) => !draftTags.includes(s)) && (
|
||||
<div className="d-flex flex-wrap align-items-center gap-1 mt-2">
|
||||
<span className="text-secondary small me-1">Add:</span>
|
||||
{suggestions
|
||||
.filter((s) => !draftTags.includes(s))
|
||||
.slice(0, 30)
|
||||
.map((s) => {
|
||||
const { className, style } = tagBadge(s, colors);
|
||||
return (
|
||||
<button key={s} type="button" className={`${className} border-0`} style={{ ...style, cursor: "pointer" }} onClick={() => setDraftTags([...draftTags, s])}>
|
||||
+ {s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-secondary small mt-2">Lowercase letters, numbers and - _ . : / — up to 12 tags per server.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { TagBadges } from "../components/ServerTags";
|
||||
import { tagColorClass } from "../utils/tags";
|
||||
import { tagBadge, useTagColors } from "../utils/tags";
|
||||
|
||||
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
|
||||
const color = colors[type];
|
||||
@@ -40,6 +40,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
// The active tag filter lives in the URL, so a tag chip on a server's page can link straight to "everything with this tag".
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedTags = searchParams.getAll("tag");
|
||||
const tagColors = useTagColors();
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {});
|
||||
@@ -342,11 +343,13 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
<span className="text-secondary small me-1">Tags</span>
|
||||
{allTags.map((tag) => {
|
||||
const active = selectedTags.includes(tag);
|
||||
const look = tagBadge(tag, tagColors);
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className={`badge border-0 ${active ? "bg-primary text-white" : tagColorClass(tag)}`}
|
||||
className={active ? "badge border-0 bg-primary text-white" : `${look.className} border-0`}
|
||||
style={active ? undefined : look.style}
|
||||
aria-pressed={active}
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ const SUB_NAV = [
|
||||
{ to: "/settings/notifications", label: "Notifications" },
|
||||
{ to: "/settings/badges", label: "Badges" },
|
||||
{ to: "/settings/display", label: "Display" },
|
||||
{ to: "/settings/tags", label: "Tags" },
|
||||
{ to: "/settings/cache", label: "Cache" },
|
||||
{ to: "/settings/logs", label: "Logs" },
|
||||
{ to: "/settings/backup", label: "Backup" },
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type TagEntry } from "../../api/client";
|
||||
import { readableError } from "../../utils/errors";
|
||||
import { normalizeTagInput, refreshTagColors, tagBadge } from "../../utils/tags";
|
||||
|
||||
const DEFAULT_PICK = "#3b82f6";
|
||||
|
||||
export default function TagSettings() {
|
||||
const [tags, setTags] = useState<TagEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newColor, setNewColor] = useState<string | null>(null);
|
||||
// A colour being picked but not saved yet, per tag.
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
api.tags
|
||||
.list()
|
||||
.then((res) => setTags(res.tags))
|
||||
.catch((err) => setError(readableError(err)));
|
||||
}, []);
|
||||
|
||||
/** Runs one change, shows the outcome, and refreshes the colours every tag on screen is drawn with. */
|
||||
async function run<T extends { tags: TagEntry[] }>(action: () => Promise<T>, success?: (res: T) => string | null): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await action();
|
||||
setTags(res.tags);
|
||||
if (success) setNotice(success(res));
|
||||
await refreshTagColors();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(readableError(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function add(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (await run(() => api.tags.create(newName, newColor ?? undefined), () => `Added “${normalizeTagInput(newName)}”.`)) {
|
||||
setNewName("");
|
||||
setNewColor(null);
|
||||
}
|
||||
}
|
||||
|
||||
function setDraft(name: string, color: string | null) {
|
||||
setDrafts((d) => {
|
||||
const next = { ...d };
|
||||
if (color === null) delete next[name];
|
||||
else next[name] = color;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveColor(t: TagEntry, color: string | null) {
|
||||
if (await run(() => api.tags.setColor(t.name, color))) setDraft(t.name, null);
|
||||
}
|
||||
|
||||
async function rename(t: TagEntry) {
|
||||
const input = window.prompt(`Rename “${t.name}” to:`, t.name);
|
||||
if (input === null) return;
|
||||
const to = normalizeTagInput(input);
|
||||
if (!to || to === t.name) return;
|
||||
const target = tags?.find((x) => x.name === to);
|
||||
if (target && !window.confirm(`“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`)) return;
|
||||
await run(
|
||||
() => api.tags.rename(t.name, to),
|
||||
(res) => `${res.merged ? "Merged" : "Renamed"} “${t.name}” ${res.merged ? "into" : "to"} “${to}” — ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"} updated.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function remove(t: TagEntry) {
|
||||
const used = t.count > 0 ? ` It's on ${t.count} server${t.count === 1 ? "" : "s"} and will be removed from ${t.count === 1 ? "it" : "them"}.` : "";
|
||||
if (!window.confirm(`Delete the tag “${t.name}”?${used}`)) return;
|
||||
await run(
|
||||
() => api.tags.remove(t.name),
|
||||
(res) => `Deleted “${t.name}”${res.updatedServers > 0 ? ` and removed it from ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"}` : ""}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const previewName = normalizeTagInput(newName);
|
||||
const preview = tagBadge(previewName, newColor ? { [previewName]: newColor } : {});
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{notice && <div className="alert alert-success">{notice}</div>}
|
||||
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Add a tag</h3>
|
||||
</div>
|
||||
<form onSubmit={add}>
|
||||
<div className="card-body">
|
||||
<div className="text-secondary small mb-3">
|
||||
Tags added here are offered when tagging a server, before any server uses them. Tags that servers already have are listed below
|
||||
automatically.
|
||||
</div>
|
||||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||||
<input
|
||||
className="form-control"
|
||||
style={{ maxWidth: 260 }}
|
||||
placeholder="e.g. prod, media, rack-1"
|
||||
maxLength={40}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<label className="form-check mb-0">
|
||||
<input type="checkbox" className="form-check-input" checked={newColor !== null} onChange={(e) => setNewColor(e.target.checked ? DEFAULT_PICK : null)} />
|
||||
<span className="form-check-label">Choose a colour</span>
|
||||
</label>
|
||||
{newColor !== null && (
|
||||
<input type="color" className="form-control form-control-color" value={newColor} onChange={(e) => setNewColor(e.target.value)} aria-label="Colour" />
|
||||
)}
|
||||
{previewName && (
|
||||
<span className={preview.className} style={preview.style}>
|
||||
{previewName}
|
||||
</span>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary ms-auto" disabled={busy || !newName.trim()}>
|
||||
Add tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">All tags</h3>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Servers</th>
|
||||
<th>Colour</th>
|
||||
<th className="w-1">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags?.map((t) => {
|
||||
const draft = drafts[t.name];
|
||||
const shown = draft ?? t.color;
|
||||
const badge = tagBadge(t.name, shown ? { [t.name]: shown } : {});
|
||||
return (
|
||||
<tr key={t.name}>
|
||||
<td>
|
||||
<span className={badge.className} style={badge.style}>
|
||||
{t.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-secondary">{t.count === 0 ? "not used yet" : t.count}</td>
|
||||
<td>
|
||||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||
{shown ? (
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color"
|
||||
value={shown}
|
||||
aria-label={`Colour for ${t.name}`}
|
||||
onChange={(e) => setDraft(t.name, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-secondary small">Automatic</span>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => setDraft(t.name, DEFAULT_PICK)}>
|
||||
Choose…
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{draft && draft !== t.color && (
|
||||
<>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void saveColor(t, draft)} disabled={busy}>
|
||||
Save
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setDraft(t.name, null)} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{t.color && !(draft && draft !== t.color) && (
|
||||
<button className="btn btn-sm btn-link p-0" onClick={() => void saveColor(t, null)} disabled={busy}>
|
||||
Reset to automatic
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => void rename(t)} disabled={busy}>
|
||||
Rename
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => void remove(t)} disabled={busy}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{tags && tags.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-secondary text-center">
|
||||
No tags yet. Add one above, or tag a server from its page.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="card-footer text-secondary small">
|
||||
Renaming a tag to a name that already exists merges them. Deleting a tag removes it from every server that has it. “Automatic” colours
|
||||
are picked from the tag's name, so they stay the same on every page.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+49
-1
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const PALETTE = ["blue", "azure", "indigo", "purple", "pink", "orange", "yellow", "lime", "green", "teal", "cyan", "red"];
|
||||
|
||||
/** The same tag always gets the same colour, so "prod" is recognisable at a glance across every server. */
|
||||
/** The automatic colour: the same tag always gets the same one, so "prod" is recognisable at a glance across every server. */
|
||||
export function tagColorClass(tag: string): string {
|
||||
let hash = 0;
|
||||
for (const ch of tag) hash = (hash * 31 + ch.codePointAt(0)!) >>> 0;
|
||||
@@ -12,3 +15,48 @@ export function tagColorClass(tag: string): string {
|
||||
export function normalizeTagInput(raw: string): string {
|
||||
return raw.trim().toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
/** How to draw a tag: its chosen colour if it has one (same tinted style as the other colour-customised badges), else the automatic one. */
|
||||
export function tagBadge(tag: string, colors: Record<string, string>): { className: string; style?: CSSProperties } {
|
||||
const color = colors[tag];
|
||||
if (!color) return { className: `badge ${tagColorClass(tag)}` };
|
||||
return { className: "badge", style: { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` } };
|
||||
}
|
||||
|
||||
// ─── Shared colour map ──────────────────────────────────────────────────────
|
||||
// Tags are drawn on several pages; they all read one cached map so a colour change shows up everywhere without a reload.
|
||||
|
||||
let cache: Record<string, string> | null = null;
|
||||
let inflight: Promise<void> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** Re-reads the tag colours from the server and tells every mounted tag. Failures leave the automatic colours in place. */
|
||||
export function refreshTagColors(): Promise<void> {
|
||||
inflight = api.tags
|
||||
.list()
|
||||
.then((res) => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const t of res.tags) if (t.color) next[t.name] = t.color;
|
||||
cache = next;
|
||||
listeners.forEach((l) => l());
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function useTagColors(): Record<string, string> {
|
||||
const [colors, setColors] = useState<Record<string, string>>(cache ?? {});
|
||||
useEffect(() => {
|
||||
const update = () => setColors(cache ?? {});
|
||||
listeners.add(update);
|
||||
if (cache) update();
|
||||
else if (!inflight) void refreshTagColors();
|
||||
return () => {
|
||||
listeners.delete(update);
|
||||
};
|
||||
}, []);
|
||||
return colors;
|
||||
}
|
||||
Reference in new issue
Block a user