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
+9
View File
@@ -1,6 +1,7 @@
export type AppRoute =
| { name: "home" }
| { name: "projects" }
| { name: "brandKit" }
| { name: "project"; id: string }
| { name: "share"; id: string }
| { name: "notFound" };
@@ -23,6 +24,9 @@ function routeFromPath(path: string): AppRoute | null {
if (normalized === "/projects") {
return { name: "projects" };
}
if (normalized === "/brand-kit") {
return { name: "brandKit" };
}
const match = normalized.match(/^\/projects?\/([^/]+)$/);
if (match) {
return { name: "project", id: decodeURIComponent(match[1]) };
@@ -57,6 +61,11 @@ export function openProjects() {
window.location.hash = "/projects";
}
export function openBrandKit() {
if (typeof window === "undefined") return;
window.location.hash = "/brand-kit";
}
export function sharePath(id: string) {
return `/share/${encodeURIComponent(id)}`;
}
+504
View File
@@ -0,0 +1,504 @@
export type BrandKitLogoVariant = "primary" | "secondary" | "mark";
export type BrandKitLogo = {
id: string;
name: string;
variant: BrandKitLogoVariant;
usage: string;
source: "upload" | "moteva-mark";
dataUrl?: string;
};
export type BrandKitColor = {
id: string;
name: string;
hex: string;
usage: string;
};
export type BrandKitColorGroup = {
id: string;
name: string;
colors: BrandKitColor[];
};
export type BrandKitReferenceImage = {
id: string;
name: string;
description: string;
source: "upload" | "moteva-board";
dataUrl?: string;
};
export type BrandKitFontFile = {
name: string;
format: string;
size: number;
dataUrl: string;
};
export type BrandKitTypography = {
id: string;
label: string;
family: string;
weight: string;
sizes: string;
description: string;
fontFile?: BrandKitFontFile;
};
export type BrandKit = {
version: 1;
id: string;
name: string;
brandName: string;
tagline: string;
summary: string;
audience: string;
positioning: string;
personality: string;
voice: {
traits: string;
guidance: string;
sample: string;
};
logos: BrandKitLogo[];
coverImage?: BrandKitReferenceImage;
colorGroups: BrandKitColorGroup[];
typography: BrandKitTypography[];
referenceImages: BrandKitReferenceImage[];
visual: {
keywords: string;
composition: string;
imagery: string;
dos: string;
donts: string;
prompt: string;
};
applyToNewProjects: boolean;
createdAt: string;
updatedAt: string;
};
export type BrandKitLocale = "zh-CN" | "en-US";
export function createBrandKit(name: string, locale: BrandKitLocale = "zh-CN"): BrandKit {
const now = new Date().toISOString();
return {
version: 1,
id: createBrandKitId("brand-kit"),
name,
brandName: "",
tagline: "",
summary: "",
audience: "",
positioning: "",
personality: "",
voice: {
traits: "",
guidance: "",
sample: ""
},
logos: [],
coverImage: undefined,
colorGroups: createDefaultColorGroups(locale),
typography: createDefaultTypography(locale),
referenceImages: [],
visual: {
keywords: "",
composition: "",
imagery: "",
dos: "",
donts: "",
prompt: ""
},
applyToNewProjects: false,
createdAt: now,
updatedAt: now
};
}
export function createMotevaBrandKitExample(locale: BrandKitLocale): BrandKit {
const kit = createBrandKit(locale === "zh-CN" ? "Moteva 核心品牌" : "Moteva Core Brand", locale);
const chinese = locale === "zh-CN";
return {
...kit,
id: "moteva-brand-kit-example",
brandName: "Moteva",
tagline: chinese ? "让设计进入流动状态" : "Keep every idea in motion",
summary: chinese
? "Moteva 是面向创意团队与独立创作者的 AI 设计伙伴,把模糊想法转化为清晰、可继续编辑的视觉成果。"
: "Moteva is an AI design partner for creative teams and independent makers, turning early ideas into clear, editable visual work.",
audience: chinese
? "需要快速探索方向、同时重视品牌一致性的设计师、市场团队与创业者。"
: "Designers, marketing teams, and founders who need to explore quickly without losing brand consistency.",
positioning: chinese
? "不是一次性的图片生成器,而是能够理解上下文、延续判断并共同完成设计的工作空间。"
: "Not a one-off image generator, but a workspace that understands context, carries decisions forward, and helps finish the design.",
personality: chinese ? "清晰、敏锐、克制、协作、持续进化" : "Clear, perceptive, restrained, collaborative, evolving",
voice: {
traits: chinese ? "直接但不生硬;专业但不炫技;主动给出下一步" : "Direct, never abrupt; expert, never showy; always offers a useful next step",
guidance: chinese
? "先说结论,再补充判断依据。使用具体动词和可执行建议,避免空泛口号、夸张承诺与过度技术化表达。"
: "Lead with the outcome, then add the reasoning. Use concrete verbs and actionable guidance. Avoid vague slogans, inflated claims, and needless technical language.",
sample: chinese ? "把复杂的设计问题,变成下一步可执行的选择。" : "Turn a complex design problem into the next clear decision."
},
logos: [
{
id: "moteva-example-logo",
name: chinese ? "Moteva 主标志" : "Moteva primary mark",
variant: "primary",
usage: chinese ? "用于产品、品牌页与浅色背景;四周保留一个标志宽度的安全空间。" : "Use across product and brand surfaces on light backgrounds. Keep one mark-width of clear space.",
source: "moteva-mark"
}
],
coverImage: {
id: "moteva-example-cover",
name: chinese ? "Moteva 品牌封面" : "Moteva brand cover",
description: chinese ? "用于品牌套件列表的识别封面。" : "Recognition cover for the Brand Kit library.",
source: "moteva-board"
},
colorGroups: [
createColorGroup("primary", chinese ? "主色" : "Primary", [
createColor("ink", chinese ? "墨黑" : "Ink", "#0B0B0C", chinese ? "主文字、品牌标志、关键动作" : "Primary text, brand mark, and key actions")
]),
createColorGroup("secondary", chinese ? "辅助色" : "Secondary", [
createColor("action", chinese ? "行动蓝" : "Action Blue", "#2F66F6", chinese ? "链接、焦点与高价值交互" : "Links, focus, and high-value interactions")
]),
createColorGroup("accent", chinese ? "强调色" : "Accent", [
createColor("signal", chinese ? "信号青柠" : "Signal Lime", "#DDFB70", chinese ? "智能能力、完成状态与少量强调" : "Intelligence cues, completion, and sparing emphasis")
]),
createColorGroup("neutral", chinese ? "中性色" : "Neutral", [
createColor("paper", chinese ? "纸白" : "Paper", "#F7F7F5", chinese ? "画布、留白与安静背景" : "Canvas, negative space, and quiet backgrounds"),
createColor("soft-gray", chinese ? "雾灰" : "Mist", "#E8E9E5", chinese ? "分隔、次级表面与安静层级" : "Dividers, secondary surfaces, and quiet hierarchy")
])
],
typography: [
createTypography(chinese ? "标题字体" : "Heading type", "Avenir Next", "700", "28 / 40 / 56", chinese ? "标题与关键结论,使用紧凑层级和充足留白。" : "Headlines and key conclusions with compact hierarchy and generous space."),
createTypography(chinese ? "正文字体" : "Body type", "Inter", "500", "12 / 14 / 16 / 20", chinese ? "产品界面、正文与说明,优先保证清晰阅读。" : "Product UI, body copy, and guidance with clarity first."),
createTypography(chinese ? "强调字体" : "Accent type", "IBM Plex Mono", "500", "11 / 12", chinese ? "模型名称、参数与系统状态,少量使用。" : "Model names, parameters, and system states, used sparingly.")
],
referenceImages: [
{
id: "moteva-example-reference",
name: chinese ? "Moteva 产品视觉" : "Moteva product visual",
description: chinese
? "高留白的真实设计画布;黑白作为结构,信号青柠只标记智能能力和关键状态。"
: "A spacious real design canvas. Black and white provide structure; signal lime marks intelligence and key states only.",
source: "moteva-board"
}
],
visual: {
keywords: chinese ? "精准、流动、克制、技术感、人性化" : "Precise, fluid, restrained, technical, human",
composition: chinese
? "用一个明确主视觉建立重心;辅助信息按严格网格排列。先用尺度、留白和对齐创造层级,再考虑装饰。"
: "Build focus around one decisive visual moment. Arrange supporting information on a disciplined grid. Create hierarchy with scale, space, and alignment before decoration.",
imagery: chinese
? "展示真实设计成果和可检查的细节。图像保持清晰、自然、具有方向性;避免只提供氛围而无法看清主体。"
: "Show real design outcomes and inspectable details. Imagery should feel clear, natural, and directed, never atmospheric at the expense of the subject.",
dos: chinese
? "使用黑白基底;把信号青柠用于智能能力提示;保持内容优先;让动效解释状态变化。"
: "Use a black-and-white foundation; reserve signal lime for intelligence cues; keep content first; let motion explain state changes.",
donts: chinese
? "避免装饰性渐变、漂浮光球、泛化图库人物、过圆卡片和无意义的营销式大标题。不要用颜色替代清晰的信息层级。"
: "Avoid decorative gradients, floating glow shapes, generic stock people, overly rounded cards, and empty marketing-scale headlines. Do not use color in place of hierarchy.",
prompt: chinese
? "为 Moteva 创建设计:以清晰、克制、持续流动为核心。使用墨黑与纸白作为基底,信号青柠仅作少量智能提示;突出一个主视觉,以严格网格、充足留白和可检查的真实细节保持专业感。"
: "Create a design for Moteva around clarity, restraint, and continuous motion. Use ink and paper as the foundation with signal lime only for intelligence cues. Establish one visual focus, then use a disciplined grid, generous space, and inspectable real detail."
},
applyToNewProjects: true
};
}
export function cloneBrandKit(source: BrandKit, name: string): BrandKit {
const now = new Date().toISOString();
return {
...source,
id: createBrandKitId("brand-kit"),
name,
logos: source.logos.map((logo) => ({ ...logo, id: createBrandKitId("logo") })),
coverImage: source.coverImage ? { ...source.coverImage, id: createBrandKitId("cover") } : undefined,
colorGroups: source.colorGroups.map((group) => ({
...group,
id: createBrandKitId("color-group"),
colors: group.colors.map((color) => ({ ...color, id: createBrandKitId("color") }))
})),
typography: source.typography.map((item) => ({ ...item, id: createBrandKitId("type"), fontFile: item.fontFile ? { ...item.fontFile } : undefined })),
referenceImages: source.referenceImages.map((image) => ({ ...image, id: createBrandKitId("reference") })),
applyToNewProjects: false,
createdAt: now,
updatedAt: now
};
}
export function brandKitCompletion(kit: BrandKit) {
const foundations = [kit.brandName, kit.summary, kit.audience, kit.positioning, kit.personality].filter(hasText).length / 5;
const voice = [kit.voice.traits, kit.voice.guidance, kit.voice.sample].filter(hasText).length / 3;
const logo = kit.logos.length > 0 ? 1 : 0;
const colors = Math.min(brandKitColors(kit).length / 4, 1);
const typography = Math.min(kit.typography.filter((item) => hasText(item.family) || Boolean(item.fontFile)).length / 3, 1);
const visual = [kit.visual.keywords, kit.visual.composition, kit.visual.imagery, kit.visual.dos, kit.visual.donts, kit.visual.prompt].filter(hasText).length / 6;
return Math.round((foundations * 0.28 + voice * 0.13 + logo * 0.13 + colors * 0.14 + typography * 0.14 + visual * 0.18) * 100);
}
export function brandKitPrompt(kit: BrandKit) {
const sections = [
promptSection("Brand", [kit.brandName, kit.tagline, kit.summary]),
promptSection("Audience", [kit.audience]),
promptSection("Positioning and personality", [kit.positioning, kit.personality]),
promptSection("Voice", [kit.voice.traits, kit.voice.guidance, kit.voice.sample]),
promptSection(
"Logo rules",
kit.logos.map((logo) => [logo.name, logo.usage].filter(hasText).join(": "))
),
promptSection("Brand cover", kit.coverImage ? [kit.coverImage.name, kit.coverImage.description] : []),
promptSection(
"Color system",
kit.colorGroups.flatMap((group) => group.colors.map((color) => `${group.name} / ${color.name || "Color"} ${color.hex}${color.usage ? `: ${color.usage}` : ""}`))
),
promptSection(
"Typography",
kit.typography.map((item) => [item.label, item.family, item.fontFile?.name ?? "", item.weight, item.sizes, item.description].filter(hasText).join(" | "))
),
promptSection(
"Reference images",
kit.referenceImages.map((image) => [image.name, image.description].filter(hasText).join(": "))
),
promptSection("Visual direction", [kit.visual.keywords, kit.visual.composition, kit.visual.imagery]),
promptSection("Use", [kit.visual.dos]),
promptSection("Avoid", [kit.visual.donts]),
promptSection("Reusable direction", [kit.visual.prompt])
].filter(hasText);
return [`Use this Brand Kit as a binding creative system for the project: ${kit.name}.`, ...sections].join("\n\n");
}
export function createBrandKitColor(): BrandKitColor {
return createColor(createBrandKitId("color"), "", "#111111", "");
}
export function createBrandKitColorGroup(name: string): BrandKitColorGroup {
return createColorGroup(createBrandKitId("color-group"), name, []);
}
export function createBrandKitTypography(label: string): BrandKitTypography {
return createTypography(label);
}
export function createBrandKitLogo(file: File, dataUrl: string): BrandKitLogo {
return {
id: createBrandKitId("logo"),
name: file.name.replace(/\.[^.]+$/, ""),
variant: "primary",
usage: "",
source: "upload",
dataUrl
};
}
export function createBrandKitFontFile(file: File, dataUrl: string): BrandKitFontFile {
const extension = file.name.split(".").pop()?.toUpperCase() || "FONT";
return {
name: file.name,
format: extension,
size: file.size,
dataUrl
};
}
export function createBrandKitReferenceImage(file: File, dataUrl: string): BrandKitReferenceImage {
return {
id: createBrandKitId("reference"),
name: file.name.replace(/\.[^.]+$/, ""),
description: "",
source: "upload",
dataUrl
};
}
export function withBrandKitDefaults(kit: BrandKit): BrandKit {
const referenceImages = (kit as BrandKit & { referenceImages?: unknown }).referenceImages;
const coverImage = (kit as BrandKit & { coverImage?: unknown }).coverImage;
const raw = kit as BrandKit & { colorGroups?: unknown; colors?: unknown };
const colorGroups = Array.isArray(raw.colorGroups)
? (raw.colorGroups as BrandKitColorGroup[])
: Array.isArray(raw.colors) && raw.colors.length > 0
? [createColorGroup("migrated-colors", "Colors", raw.colors as BrandKitColor[])]
: [];
return {
...kit,
coverImage: coverImage && typeof coverImage === "object" ? (coverImage as BrandKitReferenceImage) : undefined,
colorGroups,
typography: kit.typography.map(normalizeTypography),
referenceImages: Array.isArray(referenceImages) ? (referenceImages as BrandKitReferenceImage[]) : [],
applyToNewProjects: kit.applyToNewProjects === true
};
}
export function brandKitColors(kit: BrandKit) {
return kit.colorGroups.flatMap((group) => group.colors);
}
export function isBrandKit(value: unknown): value is BrandKit {
if (!isRecord(value)) return false;
const candidate = value as Partial<BrandKit>;
const voice = candidate.voice;
const visual = candidate.visual;
const legacyColors = (candidate as Partial<BrandKit> & { colors?: unknown }).colors;
return (
candidate.version === 1 &&
typeof candidate.id === "string" &&
typeof candidate.name === "string" &&
typeof candidate.brandName === "string" &&
typeof candidate.tagline === "string" &&
typeof candidate.summary === "string" &&
typeof candidate.audience === "string" &&
typeof candidate.positioning === "string" &&
typeof candidate.personality === "string" &&
isRecord(voice) &&
typeof voice.traits === "string" &&
typeof voice.guidance === "string" &&
typeof voice.sample === "string" &&
Array.isArray(candidate.logos) && candidate.logos.every(isStoredLogo) &&
(candidate.coverImage === undefined || isStoredReferenceImage(candidate.coverImage)) &&
(Array.isArray(candidate.colorGroups) ? candidate.colorGroups.every(isStoredColorGroup) : Array.isArray(legacyColors) && legacyColors.every(isStoredColor)) &&
Array.isArray(candidate.typography) && candidate.typography.every(isStoredTypography) &&
(candidate.referenceImages === undefined || (Array.isArray(candidate.referenceImages) && candidate.referenceImages.every(isStoredReferenceImage))) &&
isRecord(visual) &&
typeof visual.keywords === "string" &&
typeof visual.composition === "string" &&
typeof visual.imagery === "string" &&
typeof visual.dos === "string" &&
typeof visual.donts === "string" &&
typeof visual.prompt === "string" &&
typeof candidate.createdAt === "string" &&
typeof candidate.updatedAt === "string"
);
}
function isStoredLogo(value: unknown) {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.usage === "string" &&
(value.variant === "primary" || value.variant === "secondary" || value.variant === "mark") &&
(value.source === "upload" || value.source === "moteva-mark") &&
(value.dataUrl === undefined || typeof value.dataUrl === "string")
);
}
function isStoredColorGroup(value: unknown) {
return isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && Array.isArray(value.colors) && value.colors.every(isStoredColor);
}
function isStoredColor(value: unknown) {
return isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && typeof value.hex === "string" && typeof value.usage === "string";
}
function isStoredTypography(value: unknown) {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.family === "string" &&
typeof value.weight === "string" &&
(value.label === undefined || typeof value.label === "string") &&
(value.sizes === undefined || typeof value.sizes === "string") &&
(value.description === undefined || typeof value.description === "string") &&
(value.fontFile === undefined || isStoredFontFile(value.fontFile))
);
}
function isStoredReferenceImage(value: unknown) {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.description === "string" &&
(value.source === "upload" || value.source === "moteva-board") &&
(value.dataUrl === undefined || typeof value.dataUrl === "string")
);
}
function isStoredFontFile(value: unknown) {
return (
isRecord(value) &&
typeof value.name === "string" &&
typeof value.format === "string" &&
typeof value.size === "number" &&
Number.isFinite(value.size) &&
value.size >= 0 &&
typeof value.dataUrl === "string"
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object";
}
function createBrandKitId(prefix: string) {
const random = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${random}`;
}
function createColor(id: string, name: string, hex: string, usage: string): BrandKitColor {
return { id, name, hex, usage };
}
function createColorGroup(id: string, name: string, colors: BrandKitColor[]): BrandKitColorGroup {
return { id, name, colors };
}
function createTypography(label: string, family = "", weight = "", sizes = "", description = ""): BrandKitTypography {
return {
id: createBrandKitId("type"),
label,
family,
weight,
sizes,
description
};
}
function createDefaultColorGroups(locale: BrandKitLocale) {
const chinese = locale === "zh-CN";
return [
createBrandKitColorGroup(chinese ? "主色" : "Primary"),
createBrandKitColorGroup(chinese ? "辅助色" : "Secondary"),
createBrandKitColorGroup(chinese ? "强调色" : "Accent"),
createBrandKitColorGroup(chinese ? "中性色" : "Neutral")
];
}
function createDefaultTypography(locale: BrandKitLocale) {
const chinese = locale === "zh-CN";
return [
createBrandKitTypography(chinese ? "标题字体" : "Heading type"),
createBrandKitTypography(chinese ? "正文字体" : "Body type"),
createBrandKitTypography(chinese ? "强调字体" : "Accent type")
];
}
function normalizeTypography(item: BrandKitTypography) {
const legacy = item as BrandKitTypography & { role?: string; usage?: string; sizes?: string; description?: string; label?: string };
const fallbackLabels: Record<string, string> = {
heading: "Heading type",
body: "Body type",
accent: "Accent type"
};
return {
...item,
label: legacy.label || fallbackLabels[legacy.role ?? ""] || "Typography",
sizes: legacy.sizes || "",
description: legacy.description || legacy.usage || ""
};
}
function promptSection(title: string, values: string[]) {
const content = values.map((value) => value.trim()).filter(Boolean);
return content.length > 0 ? `${title}:\n${content.join("\n")}` : "";
}
function hasText(value: string) {
return value.trim().length > 0;
}
+1
View File
@@ -71,6 +71,7 @@ export type ProjectSummary = {
brief: string;
status: string;
thumbnail: string;
brandKitId?: string;
updatedAt: string;
};
+188
View File
@@ -80,6 +80,194 @@
"likes": "likes",
"home": "Home",
"projects": "Projects",
"brandKit": "Brand Kit",
"brandKitAbout": "About Brand Kits",
"brandKitAddColor": "Add color",
"brandKitAddColorGroup": "Add color group",
"brandKitAddFirstColor": "Add first color",
"brandKitAddTypography": "Add type group",
"brandKitApplied": "Brand Kit applied to this canvas",
"brandKitApplyNewProjects": "Apply to new projects",
"brandKitBindingError": "The Brand Kit could not be applied to this canvas.",
"brandKitAssetsDescription": "Keep logos, a library cover, and visual references together as reusable source material for generation.",
"brandKitAssetsNav": "Assets",
"brandKitAssetsTitle": "Assets",
"brandKitAudience": "Core audience",
"brandKitAudiencePlaceholder": "Who are they, what situation are they in, and what matters most?",
"brandKitBoardMessage": "Ideas, kept in motion.",
"brandKitBoardType": "Brand type",
"brandKitBrandName": "Brand name",
"brandKitBrandNamePlaceholder": "For example: Moteva",
"brandKitCancel": "Cancel",
"brandKitColorDescription": "Give every color a role so generated work uses the palette intentionally.",
"brandKitColorCount": "{count} colors",
"brandKitColorGroupName": "Color group name",
"brandKitColorGroupNamePlaceholder": "For example: Primary",
"brandKitColorHex": "Hex color",
"brandKitColorName": "Color name",
"brandKitColorNav": "Color",
"brandKitColorPicker": "Choose color",
"brandKitColorTitle": "Color system",
"brandKitColorUsage": "Color usage",
"brandKitColorUsagePlaceholder": "Primary text, background, or key action",
"brandKitCleared": "Brand Kit removed from this canvas",
"brandKitCompletion": "{count}% complete",
"brandKitComposition": "Composition principles",
"brandKitCompositionPlaceholder": "Describe visual focus, grid, negative space, and hierarchy.",
"brandKitCopySuffix": "Copy",
"brandKitCreateProject": "Create project with this kit",
"brandKitCreateRequirement": "Add a brand name, summary, and audience or positioning before creating a project",
"brandKitCreate": "Create Brand Kit",
"brandKitCreatingProject": "Creating project...",
"brandKitCoverDescription": "The identifying image used for this Brand Kit in the library.",
"brandKitCoverReadError": "This cover image could not be read.",
"brandKitCoverReady": "Brand Kit cover",
"brandKitCoverSizeError": "Cover images must be under 1 MB.",
"brandKitCoverTitle": "Cover image",
"brandKitCoverTypeError": "Upload a PNG, JPG, WebP, or SVG cover.",
"brandKitCoverUploaded": "Cover image added",
"brandKitDefault": "Default",
"brandKitDelete": "Delete",
"brandKitDeleteDescription": "“{name}” will be removed from your account. Canvases using it will switch to no Brand Kit.",
"brandKitDeleteTitle": "Delete this Brand Kit?",
"brandKitDeleted": "Brand Kit deleted",
"brandKitDonts": "Avoid",
"brandKitDontsPlaceholder": "List colors, compositions, imagery, or language that weaken recognition.",
"brandKitDos": "Use",
"brandKitDosPlaceholder": "List the visual habits and brand moves that must stay consistent.",
"brandKitDuplicate": "Duplicate",
"brandKitDuplicated": "Copy created",
"brandKitEditorEyebrow": "Brand system",
"brandKitEmptyDescription": "Keep positioning, voice, logos, colors, and type in one place so every generation carries the same brand decisions forward.",
"brandKitEmptyShort": "No Brand Kits yet",
"brandKitEmptyTitle": "Make every generation feel like the same brand",
"brandKitExampleBadge": "Example",
"brandKitExampleDescription": "See how a complete set of brand inputs guides both visual and verbal expression.",
"brandKitExampleEyebrow": "Moteva example kit",
"brandKitExampleFooter": "The example is not saved to your library",
"brandKitExampleTemplateName": "Moteva Brand Template",
"brandKitFontFamily": "Font family",
"brandKitFontDescription": "Description",
"brandKitFontDescriptionPlaceholder": "Optional: hierarchy, context, and usage limits",
"brandKitFontReadError": "This font file could not be read.",
"brandKitFontSizeError": "Font files must be under 1 MB.",
"brandKitFontSizes": "Sizes",
"brandKitFontSizesPlaceholder": "Optional: 12 / 16 / 24",
"brandKitFontTypeError": "Upload a WOFF, WOFF2, TTF, or OTF font file.",
"brandKitFontUploaded": "Font uploaded and applied to the preview",
"brandKitFontUsage": "Font usage",
"brandKitFontUsagePlaceholder": "Hierarchy, sizes, and intended contexts",
"brandKitFontWeight": "Weight",
"brandKitFoundationDescription": "State who the brand is and the value it provides in the fewest useful words.",
"brandKitFoundationNav": "Foundation",
"brandKitFoundationTitle": "Brand foundation",
"brandKitImagery": "Image direction",
"brandKitImageryPlaceholder": "Describe subject, light, background, material, and finishing.",
"brandKitLibraryTitle": "My Brand Kits",
"brandKitLoadError": "Your Brand Kits could not be loaded.",
"brandKitLoading": "Loading Brand Kits",
"brandKitLogoDescription": "Upload the logo variants you use and define the boundaries for each one.",
"brandKitLogoLimitError": "Each Brand Kit can store up to 4 logos.",
"brandKitLogoMark": "Mark",
"brandKitLogoName": "Logo name",
"brandKitLogoNav": "Logo",
"brandKitLogoPrimary": "Primary",
"brandKitLogoReadError": "This logo file could not be read.",
"brandKitLogoSecondary": "Secondary",
"brandKitLogoSizeError": "Logo files must be under 1 MB.",
"brandKitLogoTitle": "Logo",
"brandKitLogoTypeError": "Upload a PNG, JPG, WebP, or SVG file.",
"brandKitLogoUsage": "Logo usage",
"brandKitLogoUsagePlaceholder": "Backgrounds, clear space, and prohibited uses",
"brandKitLogoVariant": "Logo variant",
"brandKitMoreActions": "More Brand Kit actions",
"brandKitNameLabel": "Brand Kit name",
"brandKitNew": "New",
"brandKitNewColorGroup": "New color group",
"brandKitNewTypography": "New type group",
"brandKitNone": "None",
"brandKitNoneDescription": "Use only this canvas and prompt",
"brandKitOptional": "Optional",
"brandKitPersonality": "Brand personality",
"brandKitPersonalityPlaceholder": "For example: clear, perceptive, restrained, collaborative",
"brandKitPositioning": "Positioning",
"brandKitPositioningPlaceholder": "How should people understand this brand relative to alternatives?",
"brandKitPreviewLabel": "Moteva Brand Kit preview",
"brandKitProjectInstruction": "Create an editable brand visual project from the system above. Establish one clear direction first and keep the key brand decisions visible on the canvas.",
"brandKitProjectTitle": "{name} Brand Project",
"brandKitPrompt": "Reusable generation direction",
"brandKitPromptPlaceholder": "Turn the most important brand constraints into a direction ready for generation.",
"brandKitReady": "readiness",
"brandKitReferenceDescription": "Upload visual samples that express the brand's composition, photography, material, or mood, then note what to carry forward.",
"brandKitReferenceGuidance": "Reference guidance",
"brandKitReferenceGuidancePlaceholder": "Optional: note the composition, color, texture, or treatment to inherit.",
"brandKitReferenceLimitError": "Each Brand Kit can store up to 6 reference images.",
"brandKitReferenceName": "Reference name",
"brandKitReferenceNamePlaceholder": "For example: Product photography direction",
"brandKitReferenceReadError": "This reference image could not be read.",
"brandKitReferenceSizeError": "Reference images must be under 1 MB.",
"brandKitReferenceTitle": "Reference images",
"brandKitReferenceTypeError": "Upload a PNG, JPG, WebP, or SVG reference image.",
"brandKitReferenceUploaded": "Reference image added",
"brandKitRemoveColor": "Remove color",
"brandKitRemoveColorGroup": "Remove color group",
"brandKitRemoveCover": "Remove cover image",
"brandKitRemoveFont": "Remove font file",
"brandKitRemoveLogo": "Remove logo",
"brandKitRemoveReference": "Remove reference image",
"brandKitRemoveTypography": "Remove type group",
"brandKitReplaceCover": "Replace cover",
"brandKitReplaceFont": "Replace",
"brandKitSaveError": "The Brand Kit could not be saved to your account.",
"brandKitSaveFailed": "Save failed",
"brandKitSaved": "Saved",
"brandKitSaving": "Saving",
"brandKitSectionNavLabel": "Brand Kit sections",
"brandKitSelect": "Choose Brand Kit",
"brandKitStartDescription": "Capture the decisions that shape creative work first, then add the visual assets.",
"brandKitStartTitle": "Make the brand decisions explicit",
"brandKitStrategyDescription": "Define who you are designing for and the position the brand should own.",
"brandKitStrategyNav": "Audience & position",
"brandKitStrategyTitle": "Audience and positioning",
"brandKitSummary": "Brand summary",
"brandKitSummaryPlaceholder": "Describe the brand, product, and core value in one or two sentences.",
"brandKitTagline": "Tagline",
"brandKitTaglinePlaceholder": "A short line that can represent the brand",
"brandKitTemplateAdded": "Example added to your Brand Kit library",
"brandKitTypographyAccent": "Accent type",
"brandKitTypographyBody": "Body type",
"brandKitTypographyDescription": "Upload brand fonts, create custom groups, and optionally define weight, sizes, and usage guidance.",
"brandKitTypographyHeading": "Heading type",
"brandKitTypographyNav": "Typography",
"brandKitTypographyTitle": "Typography system",
"brandKitTypographyName": "Type group name",
"brandKitTypographyNamePlaceholder": "For example: Heading type",
"brandKitUntitled": "Untitled Brand Kit",
"brandKitUnnamedBrand": "Brand name not set",
"brandKitUploadLogo": "Upload logo",
"brandKitUploadLogoHint": "PNG, JPG, WebP, or SVG up to 1 MB",
"brandKitUploadCover": "Upload cover image",
"brandKitUploadCoverHint": "Optional, 16:9 recommended, up to 1 MB",
"brandKitUploadFont": "Upload font",
"brandKitUploadFontHint": "WOFF / WOFF2 / TTF / OTF up to 1 MB",
"brandKitUploadReference": "Upload reference image",
"brandKitUploadReferenceHint": "Optional, PNG, JPG, WebP, or SVG up to 1 MB",
"brandKitUseTemplate": "Use this template",
"brandKitViewExample": "View example",
"brandKitVisualDescription": "Translate an abstract tone into reusable composition, image, and generation rules.",
"brandKitVisualKeywords": "Style keywords",
"brandKitVisualKeywordsPlaceholder": "For example: precise, natural, restrained, energetic",
"brandKitVisualNav": "Visual direction",
"brandKitVisualTitle": "Visual direction",
"brandKitVoiceDescription": "Define how the brand speaks and provide one line that makes the voice concrete.",
"brandKitVoiceGuidance": "Voice guidance",
"brandKitVoiceGuidancePlaceholder": "Describe sentence shape, vocabulary, information order, and language to avoid.",
"brandKitVoiceNav": "Voice",
"brandKitVoiceSample": "Sample line",
"brandKitVoiceSamplePlaceholder": "Write one line that sounds unmistakably like this brand.",
"brandKitVoiceTitle": "Brand voice",
"brandKitVoiceTraits": "Voice traits",
"brandKitVoiceTraitsPlaceholder": "For example: direct, never abrupt; expert, never showy",
"personalProjects": "Personal Projects",
"projectLibrarySubtitle": "Recent projects only shows three. Manage every personal canvas here.",
"editProjects": "Edit",
+188
View File
@@ -80,6 +80,194 @@
"likes": "喜欢",
"home": "首页",
"projects": "项目",
"brandKit": "品牌套件",
"brandKitAbout": "关于品牌套件",
"brandKitAddColor": "添加颜色",
"brandKitAddColorGroup": "添加颜色栏目",
"brandKitAddFirstColor": "添加第一个颜色",
"brandKitAddTypography": "添加字体栏目",
"brandKitApplied": "品牌套件已应用到当前画布",
"brandKitApplyNewProjects": "应用到新项目",
"brandKitBindingError": "无法将品牌套件应用到当前画布。",
"brandKitAssetsDescription": "集中管理品牌 Logo、封面图与视觉参考,生成时作为可复用的素材依据。",
"brandKitAssetsNav": "素材",
"brandKitAssetsTitle": "素材",
"brandKitAudience": "核心受众",
"brandKitAudiencePlaceholder": "他们是谁、处于什么场景、最在意什么?",
"brandKitBoardMessage": "Ideas, kept in motion.",
"brandKitBoardType": "品牌字体",
"brandKitBrandName": "品牌名称",
"brandKitBrandNamePlaceholder": "例如:Moteva",
"brandKitCancel": "取消",
"brandKitColorDescription": "为每个颜色写明角色,避免生成结果随意使用品牌色。",
"brandKitColorCount": "{count} 个颜色",
"brandKitColorGroupName": "颜色栏目名称",
"brandKitColorGroupNamePlaceholder": "例如:主色",
"brandKitColorHex": "十六进制色值",
"brandKitColorName": "颜色名称",
"brandKitColorNav": "颜色",
"brandKitColorPicker": "选择颜色",
"brandKitColorTitle": "颜色系统",
"brandKitColorUsage": "颜色用途",
"brandKitColorUsagePlaceholder": "用于主文字、背景或关键动作",
"brandKitCleared": "已移除当前画布的品牌套件",
"brandKitCompletion": "{count}% 完成",
"brandKitComposition": "构图原则",
"brandKitCompositionPlaceholder": "说明视觉重心、网格、留白与信息层级。",
"brandKitCopySuffix": "副本",
"brandKitCreateProject": "使用此套件创建项目",
"brandKitCreateRequirement": "补充品牌名称、简介以及受众或定位后即可创建项目",
"brandKitCreate": "创建品牌套件",
"brandKitCreatingProject": "正在创建项目...",
"brandKitCoverDescription": "作为品牌套件在列表中的识别画面。",
"brandKitCoverReadError": "无法读取这张封面图。",
"brandKitCoverReady": "品牌套件封面",
"brandKitCoverSizeError": "封面图不能超过 1 MB。",
"brandKitCoverTitle": "封面图",
"brandKitCoverTypeError": "请上传 PNG、JPG、WebP 或 SVG 封面图。",
"brandKitCoverUploaded": "封面图已添加",
"brandKitDefault": "默认",
"brandKitDelete": "删除",
"brandKitDeleteDescription": "“{name}”将从你的账户中移除,使用它的画布会切换为不使用品牌套件。",
"brandKitDeleteTitle": "删除这个品牌套件?",
"brandKitDeleted": "品牌套件已删除",
"brandKitDonts": "避免事项",
"brandKitDontsPlaceholder": "列出会削弱品牌辨识度的颜色、构图、图像或表达。",
"brandKitDos": "推荐做法",
"brandKitDosPlaceholder": "列出必须保留的视觉习惯与品牌动作。",
"brandKitDuplicate": "创建副本",
"brandKitDuplicated": "副本已创建",
"brandKitEditorEyebrow": "品牌系统",
"brandKitEmptyDescription": "集中管理品牌定位、语气、Logo、颜色与字体,让每次生成都延续同一套品牌判断。",
"brandKitEmptyShort": "还没有品牌套件",
"brandKitEmptyTitle": "让每次生成都像同一个品牌",
"brandKitExampleBadge": "示例",
"brandKitExampleDescription": "查看一套完整品牌信息如何共同约束视觉与文字表达。",
"brandKitExampleEyebrow": "Moteva 示例套件",
"brandKitExampleFooter": "示例不会写入你的品牌库",
"brandKitExampleTemplateName": "Moteva 品牌模板",
"brandKitFontFamily": "字体名称",
"brandKitFontDescription": "字体说明",
"brandKitFontDescriptionPlaceholder": "可选:适用层级、语境与使用限制",
"brandKitFontReadError": "无法读取这个字体文件。",
"brandKitFontSizeError": "字体文件不能超过 1 MB。",
"brandKitFontSizes": "字号",
"brandKitFontSizesPlaceholder": "可选:12 / 16 / 24",
"brandKitFontTypeError": "请上传 WOFF、WOFF2、TTF 或 OTF 字体文件。",
"brandKitFontUploaded": "字体已上传并应用到预览",
"brandKitFontUsage": "字体用途",
"brandKitFontUsagePlaceholder": "适用层级、字号与使用场景",
"brandKitFontWeight": "字重",
"brandKitFoundationDescription": "用最短的文字说明品牌是谁、提供什么价值。",
"brandKitFoundationNav": "品牌基础",
"brandKitFoundationTitle": "品牌基础",
"brandKitImagery": "图像风格",
"brandKitImageryPlaceholder": "描述主体、光线、背景、材质和后期处理方式。",
"brandKitLibraryTitle": "我的品牌套件",
"brandKitLoadError": "无法加载你的品牌套件。",
"brandKitLoading": "正在加载品牌套件",
"brandKitLogoDescription": "上传常用标志版本,并明确每个版本的使用边界。",
"brandKitLogoLimitError": "每个品牌套件最多保存 4 个 Logo。",
"brandKitLogoMark": "图形标",
"brandKitLogoName": "Logo 名称",
"brandKitLogoNav": "Logo",
"brandKitLogoPrimary": "主标志",
"brandKitLogoReadError": "无法读取这个 Logo 文件。",
"brandKitLogoSecondary": "辅助标志",
"brandKitLogoSizeError": "Logo 文件不能超过 1 MB。",
"brandKitLogoTitle": "Logo",
"brandKitLogoTypeError": "请上传 PNG、JPG、WebP 或 SVG 文件。",
"brandKitLogoUsage": "Logo 用途",
"brandKitLogoUsagePlaceholder": "适用背景、安全空间与禁用情况",
"brandKitLogoVariant": "Logo 类型",
"brandKitMoreActions": "更多品牌套件操作",
"brandKitNameLabel": "品牌套件名称",
"brandKitNew": "新建",
"brandKitNewColorGroup": "新颜色栏目",
"brandKitNewTypography": "新字体栏目",
"brandKitNone": "不使用",
"brandKitNoneDescription": "仅使用当前画布与输入内容",
"brandKitOptional": "非必须",
"brandKitPersonality": "品牌个性",
"brandKitPersonalityPlaceholder": "例如:清晰、敏锐、克制、协作",
"brandKitPositioning": "品牌定位",
"brandKitPositioningPlaceholder": "与替代方案相比,你希望用户如何理解这个品牌?",
"brandKitPreviewLabel": "Moteva 品牌套件预览",
"brandKitProjectInstruction": "基于以上品牌系统创建一个可继续编辑的品牌视觉项目。先给出一个明确主方向,并把关键品牌判断保留在画布中。",
"brandKitProjectTitle": "{name} 品牌项目",
"brandKitPrompt": "一句话生成指令",
"brandKitPromptPlaceholder": "把最重要的品牌约束整理成一段可直接用于生成的指令。",
"brandKitReady": "就绪度",
"brandKitReferenceDescription": "上传能够代表品牌构图、摄影、材质或氛围的视觉样本,并说明借鉴重点。",
"brandKitReferenceGuidance": "参考说明",
"brandKitReferenceGuidancePlaceholder": "可选:说明需要继承的构图、色彩、质感或处理方式。",
"brandKitReferenceLimitError": "每个品牌套件最多保存 6 张参考图。",
"brandKitReferenceName": "参考图名称",
"brandKitReferenceNamePlaceholder": "例如:产品摄影方向",
"brandKitReferenceReadError": "无法读取这张参考图。",
"brandKitReferenceSizeError": "参考图不能超过 1 MB。",
"brandKitReferenceTitle": "参考图",
"brandKitReferenceTypeError": "请上传 PNG、JPG、WebP 或 SVG 参考图。",
"brandKitReferenceUploaded": "参考图已添加",
"brandKitRemoveColor": "移除颜色",
"brandKitRemoveColorGroup": "移除颜色栏目",
"brandKitRemoveCover": "移除封面图",
"brandKitRemoveFont": "移除字体文件",
"brandKitRemoveLogo": "移除 Logo",
"brandKitRemoveReference": "移除参考图",
"brandKitRemoveTypography": "移除字体栏目",
"brandKitReplaceCover": "更换封面",
"brandKitReplaceFont": "更换",
"brandKitSaveError": "无法将品牌套件保存到你的账户。",
"brandKitSaveFailed": "保存失败",
"brandKitSaved": "已保存",
"brandKitSaving": "保存中",
"brandKitSectionNavLabel": "品牌套件章节",
"brandKitSelect": "选择品牌套件",
"brandKitStartDescription": "先写下真正影响创作判断的信息,再补充视觉资产。",
"brandKitStartTitle": "先把品牌判断写清楚",
"brandKitStrategyDescription": "界定你在为谁设计,以及品牌要占据的位置。",
"brandKitStrategyNav": "受众与定位",
"brandKitStrategyTitle": "受众与定位",
"brandKitSummary": "品牌简介",
"brandKitSummaryPlaceholder": "用 1-2 句话描述品牌、产品与核心价值。",
"brandKitTagline": "品牌标语",
"brandKitTaglinePlaceholder": "一句可以代表品牌的短句",
"brandKitTemplateAdded": "示例已添加到你的品牌库",
"brandKitTypographyAccent": "强调字体",
"brandKitTypographyBody": "正文字体",
"brandKitTypographyDescription": "上传品牌字体,自定义栏目,并按需补充字重、字号和使用说明。",
"brandKitTypographyHeading": "标题字体",
"brandKitTypographyNav": "字体",
"brandKitTypographyTitle": "字体系统",
"brandKitTypographyName": "字体栏目名称",
"brandKitTypographyNamePlaceholder": "例如:标题字体",
"brandKitUntitled": "未命名品牌套件",
"brandKitUnnamedBrand": "尚未填写品牌名称",
"brandKitUploadLogo": "上传 Logo",
"brandKitUploadLogoHint": "PNG、JPG、WebP 或 SVG,不超过 1 MB",
"brandKitUploadCover": "上传封面图",
"brandKitUploadCoverHint": "可选,推荐 16:9,不超过 1 MB",
"brandKitUploadFont": "上传字体",
"brandKitUploadFontHint": "WOFF / WOFF2 / TTF / OTF,不超过 1 MB",
"brandKitUploadReference": "上传参考图",
"brandKitUploadReferenceHint": "可选,PNG、JPG、WebP 或 SVG,不超过 1 MB",
"brandKitUseTemplate": "使用此模板",
"brandKitViewExample": "查看示例",
"brandKitVisualDescription": "把抽象调性翻译成可复用的构图、图像与生成规则。",
"brandKitVisualKeywords": "风格关键词",
"brandKitVisualKeywordsPlaceholder": "例如:精准、自然、克制、富有张力",
"brandKitVisualNav": "视觉方向",
"brandKitVisualTitle": "视觉方向",
"brandKitVoiceDescription": "规定品牌如何说话,并给出一条能直接对照的示例。",
"brandKitVoiceGuidance": "表达准则",
"brandKitVoiceGuidancePlaceholder": "说明句式、用词、信息顺序以及需要避免的表达。",
"brandKitVoiceNav": "品牌语气",
"brandKitVoiceSample": "示例文案",
"brandKitVoiceSamplePlaceholder": "写一句最像这个品牌会说的话。",
"brandKitVoiceTitle": "品牌语气",
"brandKitVoiceTraits": "语气特征",
"brandKitVoiceTraitsPlaceholder": "例如:直接但不生硬;专业但不炫技",
"personalProjects": "个人项目",
"projectLibrarySubtitle": "最近项目只保留 3 个,这里管理全部个人画布。",
"editProjects": "编辑",
@@ -0,0 +1,153 @@
import type { BrandKit } from "@/domain/brandKit";
import { isBrandKit, withBrandKitDefaults } from "@/domain/brandKit";
import { request } from "@/infrastructure/designGateway/request";
const legacyStoragePrefix = "moteva:brand-kits:v1";
const migrationPrefix = "moteva:brand-kits:server-migration:v1";
const migrations = new Map<string, Promise<void>>();
type APIBrandKit = {
id: string;
name: string;
document: string;
isDefault: boolean;
createdAt: string;
updatedAt: string;
};
type APIBrandKitListResponse = {
brandKits: APIBrandKit[];
};
type APIBrandKitResponse = {
brandKit: APIBrandKit;
};
export const brandKitRepository = {
async list(scope: string | null) {
if (!scope) return [];
await migrateLegacyKits(scope);
return listRemote();
},
async save(scope: string | null, kit: BrandKit) {
assertUserScope(scope);
const response = await request<APIBrandKitResponse>(`/api/brand-kits/${encodeURIComponent(kit.id)}`, {
method: "PUT",
body: JSON.stringify({
document: JSON.stringify(serializableBrandKit(kit)),
isDefault: kit.applyToNewProjects
})
});
return fromAPIKit(response.brandKit);
},
async remove(scope: string | null, id: string) {
assertUserScope(scope);
await request<{ id: string; deleted: boolean }>(`/api/brand-kits/${encodeURIComponent(id)}`, {
method: "DELETE"
});
return listRemote();
}
};
export function brandKitStorageScope(userId?: string) {
return userId?.trim() || null;
}
async function listRemote() {
const response = await request<APIBrandKitListResponse>("/api/brand-kits");
return response.brandKits.map(fromAPIKit).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
function fromAPIKit(value: APIBrandKit): BrandKit {
const parsed: unknown = JSON.parse(value.document);
if (!isBrandKit(parsed)) throw new Error("Invalid Brand Kit document returned by the server");
return withBrandKitDefaults({
...parsed,
id: value.id,
name: value.name,
applyToNewProjects: value.isDefault,
createdAt: value.createdAt,
updatedAt: value.updatedAt
});
}
function serializableBrandKit(kit: BrandKit): BrandKit {
return {
version: 1,
id: kit.id,
name: kit.name,
brandName: kit.brandName,
tagline: kit.tagline,
summary: kit.summary,
audience: kit.audience,
positioning: kit.positioning,
personality: kit.personality,
voice: { ...kit.voice },
logos: kit.logos.map((logo) => ({ ...logo })),
coverImage: kit.coverImage ? { ...kit.coverImage } : undefined,
colorGroups: kit.colorGroups.map((group) => ({
...group,
colors: group.colors.map((color) => ({ ...color }))
})),
typography: kit.typography.map((item) => ({
...item,
fontFile: item.fontFile ? { ...item.fontFile } : undefined
})),
referenceImages: kit.referenceImages.map((image) => ({ ...image })),
visual: { ...kit.visual },
applyToNewProjects: kit.applyToNewProjects,
createdAt: kit.createdAt,
updatedAt: kit.updatedAt
};
}
function migrateLegacyKits(scope: string) {
const active = migrations.get(scope);
if (active) return active;
const migration = runLegacyMigration(scope).finally(() => migrations.delete(scope));
migrations.set(scope, migration);
return migration;
}
async function runLegacyMigration(scope: string) {
if (typeof window === "undefined") return;
const marker = migrationKey(scope);
if (window.localStorage.getItem(marker) === "done") return;
const local = readLegacyKits(scope);
if (local.length > 0) {
const remote = await listRemote();
const remoteIds = new Set(remote.map((kit) => kit.id));
for (const kit of local) {
if (!remoteIds.has(kit.id)) await brandKitRepository.save(scope, kit);
}
}
window.localStorage.removeItem(legacyStorageKey(scope));
window.localStorage.setItem(marker, "done");
}
function readLegacyKits(scope: string) {
if (typeof window === "undefined") return [];
const raw = window.localStorage.getItem(legacyStorageKey(scope));
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isBrandKit).map(withBrandKitDefaults);
} catch {
return [];
}
}
function legacyStorageKey(scope: string) {
return `${legacyStoragePrefix}:${encodeURIComponent(scope)}`;
}
function migrationKey(scope: string) {
return `${migrationPrefix}:${encodeURIComponent(scope)}`;
}
function assertUserScope(scope: string | null): asserts scope is string {
if (!scope) throw new Error("Authenticated user scope is required");
}
@@ -13,6 +13,7 @@ export const designGateway = {
createProjectAsync: projectOperations.createProjectAsync,
getProject: projectOperations.getProject,
updateProjectTitle: projectOperations.updateProjectTitle,
updateProjectBrandKit: projectOperations.updateProjectBrandKit,
deleteProject: projectOperations.deleteProject,
deleteProjects: projectOperations.deleteProjects,
deleteAsset: assetOperations.deleteAsset,
@@ -33,6 +33,7 @@ export async function projectFromQueryProjectResponse(response: QueryProjectResp
snapshotId: response.data.snapshotId || snapshot.snapshotId || "",
canvas: response.data.canvas,
lastThreadId: "",
brandKitId: response.data.brandKitId || undefined,
viewport,
nodes,
connections: normalizeCanvasConnections(snapshot.connections, nodes),
@@ -110,6 +111,7 @@ export function mergeSavedCanvasProject(
snapshotId: draft.snapshotId,
canvas: canvas || current?.canvas || "",
lastThreadId: current?.lastThreadId ?? "",
brandKitId: current?.brandKitId,
viewport,
nodes,
connections,
@@ -55,6 +55,13 @@ function updateProjectTitle(id: string, title: string) {
});
}
function updateProjectBrandKit(id: string, brandKitId: string) {
return request<ProjectResponse>(`/api/projects/${id}/brand-kit`, {
method: "PATCH",
body: JSON.stringify({ brandKitId })
});
}
function deleteProject(id: string) {
return request<DeleteProjectResponse>(`/api/projects/${id}`, {
method: "DELETE"
@@ -79,6 +86,7 @@ export const projectOperations = {
createProjectAsync,
getProject,
updateProjectTitle,
updateProjectBrandKit,
deleteProject,
deleteProjects
};
@@ -60,6 +60,7 @@ export type QueryProjectResponse = {
projectType: number;
version: string;
snapshotId: string | null;
brandKitId?: string;
permission: string;
};
};
@@ -1,6 +1,7 @@
import type { AgentMessage, CanvasConnection, CanvasNode, CanvasViewport, ExtractNodeTextResponse, Project } from "@/domain/design";
export type ImageGenerationOptions = {
brandKitId?: string;
imageModel?: string;
imageSize?: string;
imageQuality?: string;
+2
View File
@@ -7,6 +7,7 @@ import { readRoute, type AppRoute } from "@/application/route";
import { designGateway } from "@/infrastructure/designGateway";
import { httpStatusOf } from "@/infrastructure/userMessages";
import { BrandMark } from "@/ui/components/BrandMark";
import { BrandKitRoutePage } from "@/ui/pages/BrandKitRoutePage";
import { CanvasWorkspace } from "@/ui/pages/CanvasWorkspace";
import { HomePage } from "@/ui/pages/HomePage";
import { NotFoundPage } from "@/ui/pages/NotFoundPage";
@@ -46,6 +47,7 @@ export function App() {
if (route.name === "project") return <CanvasWorkspace projectId={route.id} />;
if (route.name === "share") return <SharedCanvasRoute shareId={route.id} />;
if (route.name === "projects") return <ProjectsRoutePage />;
if (route.name === "brandKit") return <BrandKitRoutePage />;
if (route.name === "notFound") return <NotFoundPage />;
return <HomePage />;
}
@@ -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>
);
}
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} />
+8
View File
@@ -1291,6 +1291,9 @@ button:disabled {
height: 48px;
display: flex;
align-items: center;
gap: 7px;
max-width: calc(100% - 220px);
min-width: 0;
pointer-events: auto;
}
@@ -3943,9 +3946,14 @@ button:disabled {
.workspace-stage-title {
left: 6px;
right: 132px;
max-width: none;
min-width: 0;
}
.workspace-stage-title .brand-kit-selector-trigger.is-compact {
max-width: 104px;
}
.canvas-open-account-controls {
right: 8px;
}
File diff suppressed because one or more lines are too long