From a567a90ce3b8264feaf7c19ec666bb256c066257 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sat, 13 Jun 2026 15:51:49 +0200 Subject: [PATCH] feat(11-03): add TagMultiSelect component with hashed tag colors - TAG_COLORS 7-color pastel palette + getTagColorIndex() deterministic name->color hash (D-07, no stored color column) - renders tag badges with inline remove (X), calls removeTagFromService - "+" dropdown to add a new tag via Enter, calls addTagToService - closes dropdown on outside click and Escape; inline error display --- src/components/ui/tag-multi-select.tsx | 162 +++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/components/ui/tag-multi-select.tsx diff --git a/src/components/ui/tag-multi-select.tsx b/src/components/ui/tag-multi-select.tsx new file mode 100644 index 0000000..f1506c0 --- /dev/null +++ b/src/components/ui/tag-multi-select.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState, useRef, useEffect, useTransition } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { X, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { addTagToService, removeTagFromService } from "@/app/admin/catalog/actions"; + +// D-07: deterministic name -> color index, no stored `color` column. +// 7-color pastel palette, bg-*-100/text-*-900 pairs for AA contrast on white. +const TAG_COLORS = [ + "bg-blue-100 text-blue-900", + "bg-green-100 text-green-900", + "bg-purple-100 text-purple-900", + "bg-pink-100 text-pink-900", + "bg-amber-100 text-amber-900", + "bg-teal-100 text-teal-900", + "bg-orange-100 text-orange-900", +]; + +export function getTagColorIndex(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash << 5) - hash + name.charCodeAt(i); + hash |= 0; // 32-bit int + } + return Math.abs(hash) % TAG_COLORS.length; +} + +export interface TagMultiSelectProps { + tags: string[]; + serviceId: string; + onTagsChanged?: () => void; +} + +export function TagMultiSelect({ tags, serviceId, onTagsChanged }: TagMultiSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const [newTag, setNewTag] = useState(""); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + function handleAddTag() { + const trimmed = newTag.trim(); + if (!trimmed) return; + setError(null); + startTransition(async () => { + try { + await addTagToService(serviceId, trimmed); + setNewTag(""); + onTagsChanged?.(); + } catch (e) { + setError(e instanceof Error ? e.message : "Errore nel salvataggio"); + } + }); + } + + function handleRemoveTag(tagName: string) { + startTransition(async () => { + try { + await removeTagFromService(serviceId, tagName); + onTagsChanged?.(); + } catch (e) { + setError(e instanceof Error ? e.message : "Errore nella rimozione"); + } + }); + } + + return ( +
+
setIsOpen((v) => !v)} + className="flex flex-wrap gap-1 items-center px-2 py-1 rounded cursor-pointer hover:bg-[#f0f0f0] transition-colors duration-150 min-h-[28px]" + > + {tags.length === 0 ? ( + + ) : ( + tags.map((tag) => ( + + {tag} + + + )) + )} + +
+ + {isOpen && ( +
+
+ setNewTag(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddTag(); + } + if (e.key === "Escape") { + e.preventDefault(); + setIsOpen(false); + } + }} + className="text-sm h-8 ring-1 ring-primary" + disabled={isPending} + /> + +
+ {error &&

{error}

} +
+ )} +
+ ); +}