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:
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export type ProjectSummary = {
|
||||
brief: string;
|
||||
status: string;
|
||||
thumbnail: string;
|
||||
brandKitId?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user