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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import type { BrandKit } from "@/domain/brandKit";
import { openBrandKit, openHome, openProject, openProjects } from "@/application/route";
import { brandKitStorageScope } from "@/infrastructure/brandKitRepository";
import { designGateway } from "@/infrastructure/designGateway";
import { useI18n } from "@/i18n/i18n";
import { useAuth } from "@/ui/auth/AuthProvider";
import { useGlobalToast } from "@/ui/components/CanvasToast";
import { useUserMessage } from "@/ui/hooks/useUserMessage";
import { BrandKitPage } from "@/ui/pages/BrandKitPage";
import { SideNav, TopBar } from "@/ui/pages/HomeShell";
export function BrandKitRoutePage() {
const { t } = useI18n();
const { isAuthenticated, openLogin, requireAuth, user } = useAuth();
const { showError } = useGlobalToast();
const toUserMessageText = useUserMessage();
const [credits, setCredits] = useState(30);
const [creatingProject, setCreatingProject] = useState(false);
const storageScope = brandKitStorageScope(user?.id);
useEffect(() => {
if (!isAuthenticated) openLogin();
}, [isAuthenticated, openLogin]);
useEffect(() => {
if (!isAuthenticated) {
setCredits(30);
return;
}
designGateway
.dashboard()
.then((dashboard) => setCredits(dashboard.credits))
.catch(() => undefined);
}, [isAuthenticated]);
const createEmptyProject = () => {
requireAuth(async () => {
setCreatingProject(true);
try {
const { project } = await designGateway.createProjectAsync("", undefined, {
imageModel: "gpt-image-2",
allowEmptyPrompt: true
});
openProject(project.id);
} catch (error) {
showError(toUserMessageText(error, "createError"));
} finally {
setCreatingProject(false);
}
});
};
const createProjectFromKit = (kit: BrandKit) => {
requireAuth(async () => {
setCreatingProject(true);
try {
const projectPrompt = t("brandKitProjectInstruction");
const projectTitle = t("brandKitProjectTitle", { name: kit.brandName.trim() || kit.name });
const { project } = await designGateway.createProjectAsync(projectPrompt, projectTitle, {
imageModel: "gpt-image-2",
brandKitId: kit.id
});
openProject(project.id);
} catch (error) {
showError(toUserMessageText(error, "createError"));
} finally {
setCreatingProject(false);
}
});
};
return (
<div className={`home-shell brand-kit-route-shell ${isAuthenticated ? "" : "is-auth-locked"}`}>
<TopBar credits={credits} onHome={openHome} />
<SideNav
active="brandKit"
onCreate={createEmptyProject}
onHome={openHome}
onProjects={openProjects}
onBrandKit={openBrandKit}
onInspiration={openHome}
/>
<BrandKitPage storageScope={storageScope} creatingProject={creatingProject} onCreateProject={createProjectFromKit} />
</div>
);
}
@@ -2,16 +2,19 @@
import { ArrowDown, BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Trash2, Type, User, Zap } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { openHome } from "@/application/route";
import { openBrandKit, openHome } from "@/application/route";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { BrandKit } from "@/domain/brandKit";
import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project, ShareAccess } from "@/domain/design";
import { useI18n, type Locale } from "@/i18n/i18n";
import { brandKitRepository, brandKitStorageScope } from "@/infrastructure/brandKitRepository";
import { designGateway } from "@/infrastructure/designGateway";
import { httpStatusOf } from "@/infrastructure/userMessages";
import { useAuth } from "@/ui/auth/AuthProvider";
import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog";
import { ArtboardPreview } from "@/ui/components/ArtboardPreview";
import { BrandKitSelector } from "@/ui/components/BrandKitSelector";
import { CanvasAnnotationPreview } from "@/ui/components/CanvasAnnotationPreview";
import { CanvasAnnotations } from "@/ui/components/CanvasAnnotations";
import { CanvasAgentComposer, composerImageSize, defaultComposerImageSettings, type ComposerImageSettings, type ComposerMode } from "@/ui/components/CanvasAgentComposer";
@@ -542,10 +545,11 @@ const ownerWorkspaceAccess: ShareAccess = {
export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess }: { projectId: string; shareAccess?: ShareAccess }) {
const { t, locale } = useI18n();
const { isAuthenticated } = useAuth();
const { isAuthenticated, user } = useAuth();
const canEdit = shareAccess.capabilities.canEdit;
const canViewChat = shareAccess.capabilities.canViewChat;
const canManageSharing = shareAccess.capabilities.canManage;
const canManageBrandKit = shareAccess.isOwner;
const toUserMessageText = useUserMessage();
const { showToast } = useGlobalToast();
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
@@ -585,6 +589,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const [composerMode, setComposerMode] = useState<ComposerMode>("agent");
const [useAgentWebSearch, setUseAgentWebSearch] = useState(false);
const [selectedCanvasModelId, setSelectedCanvasModelId] = useState("gpt-image-2-auto");
const [brandKits, setBrandKits] = useState<BrandKit[]>([]);
const [brandKitsLoading, setBrandKitsLoading] = useState(false);
const [brandKitUpdating, setBrandKitUpdating] = useState(false);
const [composerImageSettings, setComposerImageSettings] = useState<ComposerImageSettings>(defaultComposerImageSettings);
const [canvasStageSize, setCanvasStageSize] = useState({ width: 1100, height: 760 });
const [mode, setMode] = useState<"select" | "pan">("select");
@@ -696,6 +703,32 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
.catch(() => undefined);
}, [isAuthenticated]);
useEffect(() => {
let active = true;
if (!isAuthenticated || !canManageBrandKit) {
setBrandKits([]);
setBrandKitsLoading(false);
return;
}
setBrandKitsLoading(true);
brandKitRepository
.list(brandKitStorageScope(user?.id))
.then((kits) => {
if (active) setBrandKits(kits);
})
.catch(() => {
if (!active) return;
setBrandKits([]);
showToast(t("brandKitLoadError"), "error");
})
.finally(() => {
if (active) setBrandKitsLoading(false);
});
return () => {
active = false;
};
}, [canManageBrandKit, isAuthenticated, showToast, t, user?.id]);
useEffect(() => {
if (!canViewChat) {
setAssistantPanelOpen(false);
@@ -724,6 +757,25 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
setEditingTextNodeId(null);
}, [applyProjectDocument, nodesRef]);
const selectProjectBrandKit = useCallback(
async (brandKitId: string | null) => {
const currentProject = projectRef.current;
if (!currentProject || !canManageBrandKit) return;
setBrandKitUpdating(true);
try {
const { project: updatedProject } = await designGateway.updateProjectBrandKit(currentProject.id, brandKitId ?? "");
projectRef.current = updatedProject;
setProject(updatedProject);
showToast(t(brandKitId ? "brandKitApplied" : "brandKitCleared"), "success");
} catch {
showToast(t("brandKitBindingError"), "error");
} finally {
setBrandKitUpdating(false);
}
},
[canManageBrandKit, projectRef, setProject, showToast, t]
);
const appendThinkingMessage = useCallback((message: AgentMessage) => {
if (isImageGeneratorThreadMessage(message, imageGeneratorThreadIdsRef.current)) return;
if (message.type === "text_extraction_result" || message.name === "text_extraction") return;
@@ -3473,6 +3525,16 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
onBackHome={openHome}
onRename={updateProjectTitle}
/>
{canManageBrandKit && (
<BrandKitSelector
kits={brandKits}
selectedId={project.brandKitId ?? null}
loading={brandKitsLoading || brandKitUpdating}
disabled={!canEdit || brandKitUpdating || anyGenerating}
onSelect={selectProjectBrandKit}
onCreate={openBrandKit}
/>
)}
</div>
{canViewChat ? (
assistantPanelOpen ? (
+58 -3
View File
@@ -2,16 +2,19 @@
import { Check, ChevronDown, Eye, Frame, Globe2, Heart, Image as ImageIcon, LayoutDashboard, Loader2, Paperclip, Palette, Plus, Search, Send, ShoppingBag, Sparkles, Wand2, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { openHome, openProject, openProjects } from "@/application/route";
import { openBrandKit, openHome, openProject, openProjects } from "@/application/route";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { BrandKit } from "@/domain/brandKit";
import type { Dashboard, ProjectSummary } from "@/domain/design";
import { useI18n, type Locale } from "@/i18n/i18n";
import { brandKitRepository, brandKitStorageScope } from "@/infrastructure/brandKitRepository";
import { designGateway } from "@/infrastructure/designGateway";
import { useAuth } from "@/ui/auth/AuthProvider";
import { BrandKitSelector } from "@/ui/components/BrandKitSelector";
import { useGlobalToast } from "@/ui/components/CanvasToast";
import { DeleteProjectDialog } from "@/ui/components/DeleteProjectDialog";
import { ProjectCard } from "@/ui/components/ProjectCard";
@@ -611,7 +614,7 @@ export function HomePage() {
const { t, locale } = useI18n();
const toUserMessageText = useUserMessage();
const { showError } = useGlobalToast();
const { isAuthenticated, requireAuth } = useAuth();
const { isAuthenticated, requireAuth, user } = useAuth();
const heroRef = useRef<HTMLElement | null>(null);
const projectsRef = useRef<HTMLElement | null>(null);
const inspirationRef = useRef<HTMLElement | null>(null);
@@ -629,6 +632,9 @@ export function HomePage() {
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [useWebReference, setUseWebReference] = useState(false);
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [brandKits, setBrandKits] = useState<BrandKit[]>([]);
const [selectedBrandKitId, setSelectedBrandKitId] = useState<string | null>(null);
const [brandKitSelectionLoaded, setBrandKitSelectionLoaded] = useState(false);
const [activeModelTab, setActiveModelTab] = useState<HomeModelTab>("image");
const [selectedModelId, setSelectedModelId] = useState("gpt-image-2-auto");
const [loading, setLoading] = useState(false);
@@ -644,6 +650,36 @@ export function HomePage() {
.catch((err: unknown) => showError(toUserMessageText(err, "createError")));
}, [isAuthenticated, showError, toUserMessageText]);
useEffect(() => {
let active = true;
if (!isAuthenticated) {
setBrandKits([]);
setSelectedBrandKitId(null);
setBrandKitSelectionLoaded(false);
return;
}
setBrandKitSelectionLoaded(false);
brandKitRepository
.list(brandKitStorageScope(user?.id))
.then((kits) => {
if (!active) return;
setBrandKits(kits);
setSelectedBrandKitId((current) => (current && kits.some((kit) => kit.id === current) ? current : kits.find((kit) => kit.applyToNewProjects)?.id ?? null));
})
.catch((err: unknown) => {
if (!active) return;
setBrandKits([]);
setSelectedBrandKitId(null);
showError(toUserMessageText(err, "createError"));
})
.finally(() => {
if (active) setBrandKitSelectionLoaded(true);
});
return () => {
active = false;
};
}, [isAuthenticated, showError, toUserMessageText, user?.id]);
useEffect(() => {
referenceImagesRef.current = referenceImages;
}, [referenceImages]);
@@ -653,6 +689,7 @@ export function HomePage() {
const promptModes = useMemo(() => getPromptModes(locale), [locale]);
const imageModels = useMemo(() => getImageModels(locale), [locale]);
const selectedModel = imageModels.find((model) => model.id === selectedModelId) ?? imageModels[0];
const selectedBrandKit = brandKits.find((kit) => kit.id === selectedBrandKitId) ?? null;
const inspirationCategories = useMemo(() => getInspirationCategories(locale), [locale]);
const inspirationCatalog = useMemo(() => getInspirationCatalog(locale), [locale]);
const promptPlaceholder = useHomePromptPlaceholder(locale);
@@ -700,10 +737,12 @@ export function HomePage() {
if (!options.allowEmptyPrompt && !hasPromptText(nextPrompt ?? prompt)) return;
setLoading(true);
try {
const submissionPrompt = composeHomePrompt(getSubmissionPrompt(nextPrompt), useWebReference, selectedModel, t);
const { project } = await designGateway.createProjectAsync(
composeHomePrompt(getSubmissionPrompt(nextPrompt), useWebReference, selectedModel, t),
submissionPrompt,
undefined,
{
brandKitId: brandKitSelectionLoaded ? selectedBrandKitId ?? "" : undefined,
imageModel: toImageApiModel(selectedModel.id),
enableWebSearch: useWebReference,
allowEmptyPrompt: options.allowEmptyPrompt
@@ -799,6 +838,7 @@ export function HomePage() {
onCreate={() => createProject("", { allowEmptyPrompt: true })}
onHome={() => scrollTo(heroRef.current)}
onProjects={openProjects}
onBrandKit={openBrandKit}
onInspiration={() => scrollTo(inspirationRef.current)}
/>
@@ -840,8 +880,23 @@ export function HomePage() {
<Sparkles size={13} />
{selectedModel.label}
</span>
{selectedBrandKit && (
<span>
<Palette size={13} />
{selectedBrandKit.name}
</span>
)}
</div>
<div className="prompt-spacer" />
<BrandKitSelector
kits={brandKits}
selectedId={selectedBrandKitId}
loading={isAuthenticated && !brandKitSelectionLoaded}
disabled={loading}
mode="icon"
onSelect={setSelectedBrandKitId}
onCreate={openBrandKit}
/>
<button
className={`icon-button bordered ${useWebReference ? "active" : ""}`}
aria-label={t("webReference")}
+35
View File
@@ -324,6 +324,37 @@
}
.side-nav {
top: auto;
bottom: 12px;
left: 50%;
display: flex;
align-items: center;
gap: 8px;
transform: translateX(-50%);
}
.side-nav nav {
width: auto;
height: 52px;
display: flex;
gap: 4px;
padding: 4px;
border-color: rgba(224, 227, 232, 0.82);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 34px rgba(17, 24, 39, 0.14);
}
.side-nav nav button {
width: 42px;
height: 42px;
}
.side-nav .create-fab {
width: 52px !important;
height: 52px !important;
}
.side-nav .settings-fab {
display: none;
}
}
@@ -337,4 +368,8 @@
.account-pill {
display: none;
}
.side-nav .side-nav-secondary {
display: none;
}
}
+16 -3
View File
@@ -165,12 +165,14 @@ export function SideNav({
onCreate,
onHome,
onProjects,
onBrandKit,
onInspiration
}: {
active?: "home" | "projects";
active?: "home" | "projects" | "brandKit";
onCreate: () => void;
onHome: () => void;
onProjects: () => void;
onBrandKit: () => void;
onInspiration: () => void;
}) {
const { t } = useI18n();
@@ -187,10 +189,13 @@ export function SideNav({
<button className={active === "projects" ? "active" : ""} aria-label={t("projects")} onClick={onProjects}>
<Folder size={22} />
</button>
<button aria-label={t("members")} onClick={onInspiration}>
<button className={active === "brandKit" ? "active" : ""} aria-label={t("brandKit")} title={t("brandKit")} onClick={onBrandKit}>
<BrandKitNavIcon />
</button>
<button className="side-nav-secondary" aria-label={t("members")} onClick={onInspiration}>
<User size={22} />
</button>
<button aria-label={t("info")} onClick={onInspiration}>
<button className="side-nav-secondary" aria-label={t("info")} onClick={onInspiration}>
<Info size={22} />
</button>
</nav>
@@ -201,6 +206,14 @@ export function SideNav({
);
}
function BrandKitNavIcon() {
return (
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
<path d="M12 2C17.5222 2 22 5.97778 22 10.8889C22 13.9556 19.5111 16.4444 16.4444 16.4444H14.4778C13.5556 16.4444 12.8111 17.1889 12.8111 18.1111C12.8111 18.5333 12.9778 18.9222 13.2333 19.2111C13.5 19.5111 13.6667 19.9 13.6667 20.3333C13.6667 21.2556 12.9 22 12 22C6.47778 22 2 17.5222 2 12C2 6.47778 6.47778 2 12 2ZM10.8111 18.1111C10.8111 16.0843 12.451 14.4444 14.4778 14.4444H16.4444C18.4065 14.4444 20 12.851 20 10.8889C20 7.1392 16.4677 4 12 4C7.58235 4 4 7.58235 4 12C4 16.19 7.2226 19.6285 11.324 19.9718C10.9948 19.4168 10.8111 18.7761 10.8111 18.1111ZM7.5 12C6.67157 12 6 11.3284 6 10.5C6 9.67157 6.67157 9 7.5 9C8.32843 9 9 9.67157 9 10.5C9 11.3284 8.32843 12 7.5 12ZM16.5 12C15.6716 12 15 11.3284 15 10.5C15 9.67157 15.6716 9 16.5 9C17.3284 9 18 9.67157 18 10.5C18 11.3284 17.3284 12 16.5 12ZM12 9C11.1716 9 10.5 8.32843 10.5 7.5C10.5 6.67157 11.1716 6 12 6C12.8284 6 13.5 6.67157 13.5 7.5C13.5 8.32843 12.8284 9 12 9Z" />
</svg>
);
}
export function SectionHeader({ title, action, onAction }: { title: string; action?: string; onAction?: () => void }) {
return (
<div className="section-header">
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { openHome, openProject, openProjects } from "@/application/route";
import { openBrandKit, openHome, openProject, openProjects } from "@/application/route";
import { designGateway } from "@/infrastructure/designGateway";
import { useAuth } from "@/ui/auth/AuthProvider";
import { useGlobalToast } from "@/ui/components/CanvasToast";
@@ -64,6 +64,7 @@ export function ProjectsRoutePage() {
}
openLogin();
}}
onBrandKit={openBrandKit}
onInspiration={openHome}
/>
<ProjectsPage locked={!isAuthenticated} creating={creating} onCreateProject={createProject} />