feat(frontend): add brand kit management UI and project binding

Add a brand kit domain model, repository, and dedicated page/route for
managing brand kits, plus a BrandKitSelector used on the home page and
canvas workspace. Home project creation and the workspace can attach a
brand kit to a project, with new i18n strings and a side-nav entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:59:05 +08:00
parent 789f51b29e
commit 2df9ae6581
24 changed files with 4885 additions and 10 deletions
@@ -0,0 +1,242 @@
.brand-kit-selector-trigger {
min-width: 0;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid #e4e6e9;
border-radius: 7px;
color: #50545b;
background: #fff;
transition: border-color 150ms ease, background-color 150ms ease, color 150ms ease;
}
.brand-kit-selector-trigger:hover,
.brand-kit-selector-trigger.is-open {
border-color: #cfd2d7;
color: #17191d;
background: #f6f7f8;
}
.brand-kit-selector-trigger:disabled {
color: #a8acb3;
background: #f8f8f8;
cursor: not-allowed;
}
.brand-kit-selector-trigger.is-icon {
width: 42px;
height: 42px;
padding: 0;
border-radius: 999px;
}
.brand-kit-selector-trigger.is-compact {
max-width: 168px;
flex-shrink: 1;
padding: 0 9px;
font-size: 12px;
}
.brand-kit-selector-trigger.is-compact span {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-kit-selector-popover {
width: min(328px, calc(100vw - 24px));
overflow: hidden;
padding: 0;
border: 1px solid #e1e4e8;
border-radius: 8px;
background: #fff;
box-shadow: 0 18px 48px rgba(18, 22, 29, 0.16), 0 2px 8px rgba(18, 22, 29, 0.06);
}
.brand-kit-selector-heading {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
border-bottom: 1px solid #eef0f2;
}
.brand-kit-selector-heading strong {
color: #202329;
font-size: 14px;
font-weight: 720;
}
.brand-kit-selector-heading span {
padding: 2px 6px;
border-radius: 5px;
color: #556149;
background: #edf4e4;
font-size: 10px;
font-weight: 700;
}
.brand-kit-selector-options {
max-height: 292px;
display: grid;
gap: 6px;
overflow: auto;
overscroll-behavior: contain;
padding: 8px;
}
.brand-kit-selector-options > button {
width: 100%;
min-height: 58px;
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 20px;
align-items: center;
gap: 11px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 8px;
color: #30343a;
background: #fff;
text-align: left;
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.brand-kit-selector-options > button:hover {
border-color: #e8ebee;
background: #f8f9fa;
}
.brand-kit-selector-options > button.is-active {
border-color: #dfe3e7;
background: #f4f5f6;
box-shadow: inset 0 0 0 1px rgba(32, 35, 41, 0.02);
}
.brand-kit-selector-options > button:focus-visible {
outline: none;
border-color: #9ca3af;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px rgba(107, 114, 128, 0.35);
}
.brand-kit-selector-options > button > span:nth-child(2) {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.brand-kit-selector-options strong,
.brand-kit-selector-options small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-kit-selector-options strong {
color: #25282d;
font-size: 13px;
line-height: 1.25;
font-weight: 680;
}
.brand-kit-selector-options small {
color: #8a8f97;
font-size: 12px;
line-height: 1.25;
}
.brand-kit-selector-none,
.brand-kit-selector-swatches {
width: 40px;
height: 40px;
overflow: hidden;
display: flex;
align-items: stretch;
justify-content: center;
border: 1px solid #e4e6e9;
border-radius: 7px;
color: #747982;
background: #f8f8f7;
}
.brand-kit-selector-none {
align-items: center;
}
.brand-kit-selector-swatches i {
min-width: 0;
flex: 1;
}
.brand-kit-selector-swatches > svg {
align-self: center;
}
.brand-kit-selector-options > button > svg:last-child {
width: 20px;
height: 20px;
padding: 3px;
border-radius: 50%;
color: #fff;
background: #25282d;
}
.brand-kit-selector-loading,
.brand-kit-selector-empty {
min-height: 142px;
display: flex;
align-items: center;
justify-content: center;
color: #8a8f97;
}
.brand-kit-selector-empty {
flex-direction: column;
gap: 9px;
padding: 22px;
text-align: center;
}
.brand-kit-selector-empty strong {
color: #3d4148;
font-size: 12px;
}
.brand-kit-selector-empty button,
.brand-kit-selector-create {
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
border-radius: 6px;
color: #fff;
background: #181a1f;
font-size: 12px;
}
.brand-kit-selector-create {
width: calc(100% - 16px);
height: 36px;
margin: 2px 8px 8px;
border: 1px solid #e6e8eb;
color: #454950;
background: #f6f7f8;
font-weight: 650;
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.brand-kit-selector-create:hover,
.brand-kit-selector-empty button:hover {
background: #2c2f35;
}
.brand-kit-selector-create:hover {
border-color: #25282d;
color: #fff;
}
@@ -0,0 +1,122 @@
"use client";
import { Check, Loader2, Palette, Plus } from "lucide-react";
import { useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { brandKitColors, type BrandKit } from "@/domain/brandKit";
import { useI18n } from "@/i18n/i18n";
import "./index.css";
type BrandKitSelectorProps = {
kits: BrandKit[];
selectedId: string | null;
loading?: boolean;
disabled?: boolean;
mode?: "icon" | "compact";
onSelect: (id: string | null) => void | Promise<void>;
onCreate: () => void;
};
export function BrandKitSelector({
kits,
selectedId,
loading = false,
disabled = false,
mode = "compact",
onSelect,
onCreate
}: BrandKitSelectorProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const selected = kits.find((kit) => kit.id === selectedId) ?? null;
const select = async (id: string | null) => {
await onSelect(id);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={`brand-kit-selector-trigger ${mode === "icon" ? "is-icon" : "is-compact"} ${open ? "is-open" : ""}`}
type="button"
aria-label={t("brandKitSelect")}
title={t("brandKitSelect")}
disabled={disabled}
>
{loading ? <Loader2 className="spin" size={mode === "icon" ? 19 : 14} /> : <Palette size={mode === "icon" ? 19 : 14} />}
{mode === "compact" && <span>{selected?.name || t("brandKitNone")}</span>}
</button>
</PopoverTrigger>
<PopoverContent className="brand-kit-selector-popover" align={mode === "icon" ? "end" : "start"} sideOffset={10}>
<div className="brand-kit-selector-heading">
<strong>{t("brandKitSelect")}</strong>
{selected?.applyToNewProjects && <span>{t("brandKitDefault")}</span>}
</div>
{loading ? (
<div className="brand-kit-selector-loading">
<Loader2 className="spin" size={18} />
</div>
) : kits.length === 0 ? (
<div className="brand-kit-selector-empty">
<Palette size={22} />
<strong>{t("brandKitEmptyShort")}</strong>
<button
type="button"
onClick={() => {
setOpen(false);
onCreate();
}}
>
<Plus size={15} />
{t("brandKitCreate")}
</button>
</div>
) : (
<div className="brand-kit-selector-options" role="listbox" aria-label={t("brandKitSelect")}>
<button className={selectedId === null ? "is-active" : ""} type="button" role="option" aria-selected={selectedId === null} onClick={() => void select(null)}>
<span className="brand-kit-selector-none">
<Palette size={15} />
</span>
<span>
<strong>{t("brandKitNone")}</strong>
<small>{t("brandKitNoneDescription")}</small>
</span>
{selectedId === null && <Check size={16} />}
</button>
{kits.map((kit) => {
const colors = brandKitColors(kit).slice(0, 4);
const active = selectedId === kit.id;
return (
<button key={kit.id} className={active ? "is-active" : ""} type="button" role="option" aria-selected={active} onClick={() => void select(kit.id)}>
<span className="brand-kit-selector-swatches" aria-hidden="true">
{colors.length > 0 ? colors.map((color) => <i key={color.id} style={{ background: color.hex }} />) : <Palette size={15} />}
</span>
<span>
<strong>{kit.name}</strong>
<small>{kit.brandName || t("brandKitUnnamedBrand")}</small>
</span>
{active && <Check size={16} />}
</button>
);
})}
</div>
)}
{kits.length > 0 && !loading && (
<button
className="brand-kit-selector-create"
type="button"
onClick={() => {
setOpen(false);
onCreate();
}}
>
<Plus size={15} />
{t("brandKitCreate")}
</button>
)}
</PopoverContent>
</Popover>
);
}