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
@@ -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 ? (