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 =
|
export type AppRoute =
|
||||||
| { name: "home" }
|
| { name: "home" }
|
||||||
| { name: "projects" }
|
| { name: "projects" }
|
||||||
|
| { name: "brandKit" }
|
||||||
| { name: "project"; id: string }
|
| { name: "project"; id: string }
|
||||||
| { name: "share"; id: string }
|
| { name: "share"; id: string }
|
||||||
| { name: "notFound" };
|
| { name: "notFound" };
|
||||||
@@ -23,6 +24,9 @@ function routeFromPath(path: string): AppRoute | null {
|
|||||||
if (normalized === "/projects") {
|
if (normalized === "/projects") {
|
||||||
return { name: "projects" };
|
return { name: "projects" };
|
||||||
}
|
}
|
||||||
|
if (normalized === "/brand-kit") {
|
||||||
|
return { name: "brandKit" };
|
||||||
|
}
|
||||||
const match = normalized.match(/^\/projects?\/([^/]+)$/);
|
const match = normalized.match(/^\/projects?\/([^/]+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
return { name: "project", id: decodeURIComponent(match[1]) };
|
return { name: "project", id: decodeURIComponent(match[1]) };
|
||||||
@@ -57,6 +61,11 @@ export function openProjects() {
|
|||||||
window.location.hash = "/projects";
|
window.location.hash = "/projects";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openBrandKit() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.location.hash = "/brand-kit";
|
||||||
|
}
|
||||||
|
|
||||||
export function sharePath(id: string) {
|
export function sharePath(id: string) {
|
||||||
return `/share/${encodeURIComponent(id)}`;
|
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;
|
brief: string;
|
||||||
status: string;
|
status: string;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
|
brandKitId?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,194 @@
|
|||||||
"likes": "likes",
|
"likes": "likes",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"projects": "Projects",
|
"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",
|
"personalProjects": "Personal Projects",
|
||||||
"projectLibrarySubtitle": "Recent projects only shows three. Manage every personal canvas here.",
|
"projectLibrarySubtitle": "Recent projects only shows three. Manage every personal canvas here.",
|
||||||
"editProjects": "Edit",
|
"editProjects": "Edit",
|
||||||
|
|||||||
@@ -80,6 +80,194 @@
|
|||||||
"likes": "喜欢",
|
"likes": "喜欢",
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
"projects": "项目",
|
"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": "个人项目",
|
"personalProjects": "个人项目",
|
||||||
"projectLibrarySubtitle": "最近项目只保留 3 个,这里管理全部个人画布。",
|
"projectLibrarySubtitle": "最近项目只保留 3 个,这里管理全部个人画布。",
|
||||||
"editProjects": "编辑",
|
"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,
|
createProjectAsync: projectOperations.createProjectAsync,
|
||||||
getProject: projectOperations.getProject,
|
getProject: projectOperations.getProject,
|
||||||
updateProjectTitle: projectOperations.updateProjectTitle,
|
updateProjectTitle: projectOperations.updateProjectTitle,
|
||||||
|
updateProjectBrandKit: projectOperations.updateProjectBrandKit,
|
||||||
deleteProject: projectOperations.deleteProject,
|
deleteProject: projectOperations.deleteProject,
|
||||||
deleteProjects: projectOperations.deleteProjects,
|
deleteProjects: projectOperations.deleteProjects,
|
||||||
deleteAsset: assetOperations.deleteAsset,
|
deleteAsset: assetOperations.deleteAsset,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export async function projectFromQueryProjectResponse(response: QueryProjectResp
|
|||||||
snapshotId: response.data.snapshotId || snapshot.snapshotId || "",
|
snapshotId: response.data.snapshotId || snapshot.snapshotId || "",
|
||||||
canvas: response.data.canvas,
|
canvas: response.data.canvas,
|
||||||
lastThreadId: "",
|
lastThreadId: "",
|
||||||
|
brandKitId: response.data.brandKitId || undefined,
|
||||||
viewport,
|
viewport,
|
||||||
nodes,
|
nodes,
|
||||||
connections: normalizeCanvasConnections(snapshot.connections, nodes),
|
connections: normalizeCanvasConnections(snapshot.connections, nodes),
|
||||||
@@ -110,6 +111,7 @@ export function mergeSavedCanvasProject(
|
|||||||
snapshotId: draft.snapshotId,
|
snapshotId: draft.snapshotId,
|
||||||
canvas: canvas || current?.canvas || "",
|
canvas: canvas || current?.canvas || "",
|
||||||
lastThreadId: current?.lastThreadId ?? "",
|
lastThreadId: current?.lastThreadId ?? "",
|
||||||
|
brandKitId: current?.brandKitId,
|
||||||
viewport,
|
viewport,
|
||||||
nodes,
|
nodes,
|
||||||
connections,
|
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) {
|
function deleteProject(id: string) {
|
||||||
return request<DeleteProjectResponse>(`/api/projects/${id}`, {
|
return request<DeleteProjectResponse>(`/api/projects/${id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
@@ -79,6 +86,7 @@ export const projectOperations = {
|
|||||||
createProjectAsync,
|
createProjectAsync,
|
||||||
getProject,
|
getProject,
|
||||||
updateProjectTitle,
|
updateProjectTitle,
|
||||||
|
updateProjectBrandKit,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
deleteProjects
|
deleteProjects
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type QueryProjectResponse = {
|
|||||||
projectType: number;
|
projectType: number;
|
||||||
version: string;
|
version: string;
|
||||||
snapshotId: string | null;
|
snapshotId: string | null;
|
||||||
|
brandKitId?: string;
|
||||||
permission: string;
|
permission: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentMessage, CanvasConnection, CanvasNode, CanvasViewport, ExtractNodeTextResponse, Project } from "@/domain/design";
|
import type { AgentMessage, CanvasConnection, CanvasNode, CanvasViewport, ExtractNodeTextResponse, Project } from "@/domain/design";
|
||||||
|
|
||||||
export type ImageGenerationOptions = {
|
export type ImageGenerationOptions = {
|
||||||
|
brandKitId?: string;
|
||||||
imageModel?: string;
|
imageModel?: string;
|
||||||
imageSize?: string;
|
imageSize?: string;
|
||||||
imageQuality?: string;
|
imageQuality?: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { readRoute, type AppRoute } from "@/application/route";
|
|||||||
import { designGateway } from "@/infrastructure/designGateway";
|
import { designGateway } from "@/infrastructure/designGateway";
|
||||||
import { httpStatusOf } from "@/infrastructure/userMessages";
|
import { httpStatusOf } from "@/infrastructure/userMessages";
|
||||||
import { BrandMark } from "@/ui/components/BrandMark";
|
import { BrandMark } from "@/ui/components/BrandMark";
|
||||||
|
import { BrandKitRoutePage } from "@/ui/pages/BrandKitRoutePage";
|
||||||
import { CanvasWorkspace } from "@/ui/pages/CanvasWorkspace";
|
import { CanvasWorkspace } from "@/ui/pages/CanvasWorkspace";
|
||||||
import { HomePage } from "@/ui/pages/HomePage";
|
import { HomePage } from "@/ui/pages/HomePage";
|
||||||
import { NotFoundPage } from "@/ui/pages/NotFoundPage";
|
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 === "project") return <CanvasWorkspace projectId={route.id} />;
|
||||||
if (route.name === "share") return <SharedCanvasRoute shareId={route.id} />;
|
if (route.name === "share") return <SharedCanvasRoute shareId={route.id} />;
|
||||||
if (route.name === "projects") return <ProjectsRoutePage />;
|
if (route.name === "projects") return <ProjectsRoutePage />;
|
||||||
|
if (route.name === "brandKit") return <BrandKitRoutePage />;
|
||||||
if (route.name === "notFound") return <NotFoundPage />;
|
if (route.name === "notFound") return <NotFoundPage />;
|
||||||
return <HomePage />;
|
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 { 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 { 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 { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
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 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 { useI18n, type Locale } from "@/i18n/i18n";
|
||||||
|
import { brandKitRepository, brandKitStorageScope } from "@/infrastructure/brandKitRepository";
|
||||||
import { designGateway } from "@/infrastructure/designGateway";
|
import { designGateway } from "@/infrastructure/designGateway";
|
||||||
import { httpStatusOf } from "@/infrastructure/userMessages";
|
import { httpStatusOf } from "@/infrastructure/userMessages";
|
||||||
import { useAuth } from "@/ui/auth/AuthProvider";
|
import { useAuth } from "@/ui/auth/AuthProvider";
|
||||||
import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog";
|
import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog";
|
||||||
import { ArtboardPreview } from "@/ui/components/ArtboardPreview";
|
import { ArtboardPreview } from "@/ui/components/ArtboardPreview";
|
||||||
|
import { BrandKitSelector } from "@/ui/components/BrandKitSelector";
|
||||||
import { CanvasAnnotationPreview } from "@/ui/components/CanvasAnnotationPreview";
|
import { CanvasAnnotationPreview } from "@/ui/components/CanvasAnnotationPreview";
|
||||||
import { CanvasAnnotations } from "@/ui/components/CanvasAnnotations";
|
import { CanvasAnnotations } from "@/ui/components/CanvasAnnotations";
|
||||||
import { CanvasAgentComposer, composerImageSize, defaultComposerImageSettings, type ComposerImageSettings, type ComposerMode } from "@/ui/components/CanvasAgentComposer";
|
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 }) {
|
export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess }: { projectId: string; shareAccess?: ShareAccess }) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const canEdit = shareAccess.capabilities.canEdit;
|
const canEdit = shareAccess.capabilities.canEdit;
|
||||||
const canViewChat = shareAccess.capabilities.canViewChat;
|
const canViewChat = shareAccess.capabilities.canViewChat;
|
||||||
const canManageSharing = shareAccess.capabilities.canManage;
|
const canManageSharing = shareAccess.capabilities.canManage;
|
||||||
|
const canManageBrandKit = shareAccess.isOwner;
|
||||||
const toUserMessageText = useUserMessage();
|
const toUserMessageText = useUserMessage();
|
||||||
const { showToast } = useGlobalToast();
|
const { showToast } = useGlobalToast();
|
||||||
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
|
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 [composerMode, setComposerMode] = useState<ComposerMode>("agent");
|
||||||
const [useAgentWebSearch, setUseAgentWebSearch] = useState(false);
|
const [useAgentWebSearch, setUseAgentWebSearch] = useState(false);
|
||||||
const [selectedCanvasModelId, setSelectedCanvasModelId] = useState("gpt-image-2-auto");
|
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 [composerImageSettings, setComposerImageSettings] = useState<ComposerImageSettings>(defaultComposerImageSettings);
|
||||||
const [canvasStageSize, setCanvasStageSize] = useState({ width: 1100, height: 760 });
|
const [canvasStageSize, setCanvasStageSize] = useState({ width: 1100, height: 760 });
|
||||||
const [mode, setMode] = useState<"select" | "pan">("select");
|
const [mode, setMode] = useState<"select" | "pan">("select");
|
||||||
@@ -696,6 +703,32 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, [isAuthenticated]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!canViewChat) {
|
if (!canViewChat) {
|
||||||
setAssistantPanelOpen(false);
|
setAssistantPanelOpen(false);
|
||||||
@@ -724,6 +757,25 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
setEditingTextNodeId(null);
|
setEditingTextNodeId(null);
|
||||||
}, [applyProjectDocument, nodesRef]);
|
}, [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) => {
|
const appendThinkingMessage = useCallback((message: AgentMessage) => {
|
||||||
if (isImageGeneratorThreadMessage(message, imageGeneratorThreadIdsRef.current)) return;
|
if (isImageGeneratorThreadMessage(message, imageGeneratorThreadIdsRef.current)) return;
|
||||||
if (message.type === "text_extraction_result" || message.name === "text_extraction") return;
|
if (message.type === "text_extraction_result" || message.name === "text_extraction") return;
|
||||||
@@ -3473,6 +3525,16 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
onBackHome={openHome}
|
onBackHome={openHome}
|
||||||
onRename={updateProjectTitle}
|
onRename={updateProjectTitle}
|
||||||
/>
|
/>
|
||||||
|
{canManageBrandKit && (
|
||||||
|
<BrandKitSelector
|
||||||
|
kits={brandKits}
|
||||||
|
selectedId={project.brandKitId ?? null}
|
||||||
|
loading={brandKitsLoading || brandKitUpdating}
|
||||||
|
disabled={!canEdit || brandKitUpdating || anyGenerating}
|
||||||
|
onSelect={selectProjectBrandKit}
|
||||||
|
onCreate={openBrandKit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canViewChat ? (
|
{canViewChat ? (
|
||||||
assistantPanelOpen ? (
|
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 { 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 { 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 { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import type { BrandKit } from "@/domain/brandKit";
|
||||||
import type { Dashboard, ProjectSummary } from "@/domain/design";
|
import type { Dashboard, ProjectSummary } from "@/domain/design";
|
||||||
import { useI18n, type Locale } from "@/i18n/i18n";
|
import { useI18n, type Locale } from "@/i18n/i18n";
|
||||||
|
import { brandKitRepository, brandKitStorageScope } from "@/infrastructure/brandKitRepository";
|
||||||
import { designGateway } from "@/infrastructure/designGateway";
|
import { designGateway } from "@/infrastructure/designGateway";
|
||||||
import { useAuth } from "@/ui/auth/AuthProvider";
|
import { useAuth } from "@/ui/auth/AuthProvider";
|
||||||
|
import { BrandKitSelector } from "@/ui/components/BrandKitSelector";
|
||||||
import { useGlobalToast } from "@/ui/components/CanvasToast";
|
import { useGlobalToast } from "@/ui/components/CanvasToast";
|
||||||
import { DeleteProjectDialog } from "@/ui/components/DeleteProjectDialog";
|
import { DeleteProjectDialog } from "@/ui/components/DeleteProjectDialog";
|
||||||
import { ProjectCard } from "@/ui/components/ProjectCard";
|
import { ProjectCard } from "@/ui/components/ProjectCard";
|
||||||
@@ -611,7 +614,7 @@ export function HomePage() {
|
|||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const toUserMessageText = useUserMessage();
|
const toUserMessageText = useUserMessage();
|
||||||
const { showError } = useGlobalToast();
|
const { showError } = useGlobalToast();
|
||||||
const { isAuthenticated, requireAuth } = useAuth();
|
const { isAuthenticated, requireAuth, user } = useAuth();
|
||||||
const heroRef = useRef<HTMLElement | null>(null);
|
const heroRef = useRef<HTMLElement | null>(null);
|
||||||
const projectsRef = useRef<HTMLElement | null>(null);
|
const projectsRef = useRef<HTMLElement | null>(null);
|
||||||
const inspirationRef = 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 [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||||
const [useWebReference, setUseWebReference] = useState(false);
|
const [useWebReference, setUseWebReference] = useState(false);
|
||||||
const [modelPickerOpen, setModelPickerOpen] = 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 [activeModelTab, setActiveModelTab] = useState<HomeModelTab>("image");
|
||||||
const [selectedModelId, setSelectedModelId] = useState("gpt-image-2-auto");
|
const [selectedModelId, setSelectedModelId] = useState("gpt-image-2-auto");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -644,6 +650,36 @@ export function HomePage() {
|
|||||||
.catch((err: unknown) => showError(toUserMessageText(err, "createError")));
|
.catch((err: unknown) => showError(toUserMessageText(err, "createError")));
|
||||||
}, [isAuthenticated, showError, toUserMessageText]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
referenceImagesRef.current = referenceImages;
|
referenceImagesRef.current = referenceImages;
|
||||||
}, [referenceImages]);
|
}, [referenceImages]);
|
||||||
@@ -653,6 +689,7 @@ export function HomePage() {
|
|||||||
const promptModes = useMemo(() => getPromptModes(locale), [locale]);
|
const promptModes = useMemo(() => getPromptModes(locale), [locale]);
|
||||||
const imageModels = useMemo(() => getImageModels(locale), [locale]);
|
const imageModels = useMemo(() => getImageModels(locale), [locale]);
|
||||||
const selectedModel = imageModels.find((model) => model.id === selectedModelId) ?? imageModels[0];
|
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 inspirationCategories = useMemo(() => getInspirationCategories(locale), [locale]);
|
||||||
const inspirationCatalog = useMemo(() => getInspirationCatalog(locale), [locale]);
|
const inspirationCatalog = useMemo(() => getInspirationCatalog(locale), [locale]);
|
||||||
const promptPlaceholder = useHomePromptPlaceholder(locale);
|
const promptPlaceholder = useHomePromptPlaceholder(locale);
|
||||||
@@ -700,10 +737,12 @@ export function HomePage() {
|
|||||||
if (!options.allowEmptyPrompt && !hasPromptText(nextPrompt ?? prompt)) return;
|
if (!options.allowEmptyPrompt && !hasPromptText(nextPrompt ?? prompt)) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const submissionPrompt = composeHomePrompt(getSubmissionPrompt(nextPrompt), useWebReference, selectedModel, t);
|
||||||
const { project } = await designGateway.createProjectAsync(
|
const { project } = await designGateway.createProjectAsync(
|
||||||
composeHomePrompt(getSubmissionPrompt(nextPrompt), useWebReference, selectedModel, t),
|
submissionPrompt,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
|
brandKitId: brandKitSelectionLoaded ? selectedBrandKitId ?? "" : undefined,
|
||||||
imageModel: toImageApiModel(selectedModel.id),
|
imageModel: toImageApiModel(selectedModel.id),
|
||||||
enableWebSearch: useWebReference,
|
enableWebSearch: useWebReference,
|
||||||
allowEmptyPrompt: options.allowEmptyPrompt
|
allowEmptyPrompt: options.allowEmptyPrompt
|
||||||
@@ -799,6 +838,7 @@ export function HomePage() {
|
|||||||
onCreate={() => createProject("", { allowEmptyPrompt: true })}
|
onCreate={() => createProject("", { allowEmptyPrompt: true })}
|
||||||
onHome={() => scrollTo(heroRef.current)}
|
onHome={() => scrollTo(heroRef.current)}
|
||||||
onProjects={openProjects}
|
onProjects={openProjects}
|
||||||
|
onBrandKit={openBrandKit}
|
||||||
onInspiration={() => scrollTo(inspirationRef.current)}
|
onInspiration={() => scrollTo(inspirationRef.current)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -840,8 +880,23 @@ export function HomePage() {
|
|||||||
<Sparkles size={13} />
|
<Sparkles size={13} />
|
||||||
{selectedModel.label}
|
{selectedModel.label}
|
||||||
</span>
|
</span>
|
||||||
|
{selectedBrandKit && (
|
||||||
|
<span>
|
||||||
|
<Palette size={13} />
|
||||||
|
{selectedBrandKit.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="prompt-spacer" />
|
<div className="prompt-spacer" />
|
||||||
|
<BrandKitSelector
|
||||||
|
kits={brandKits}
|
||||||
|
selectedId={selectedBrandKitId}
|
||||||
|
loading={isAuthenticated && !brandKitSelectionLoaded}
|
||||||
|
disabled={loading}
|
||||||
|
mode="icon"
|
||||||
|
onSelect={setSelectedBrandKitId}
|
||||||
|
onCreate={openBrandKit}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
className={`icon-button bordered ${useWebReference ? "active" : ""}`}
|
className={`icon-button bordered ${useWebReference ? "active" : ""}`}
|
||||||
aria-label={t("webReference")}
|
aria-label={t("webReference")}
|
||||||
|
|||||||
@@ -324,6 +324,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-nav {
|
.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;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,4 +368,8 @@
|
|||||||
.account-pill {
|
.account-pill {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-nav .side-nav-secondary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,12 +165,14 @@ export function SideNav({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onHome,
|
onHome,
|
||||||
onProjects,
|
onProjects,
|
||||||
|
onBrandKit,
|
||||||
onInspiration
|
onInspiration
|
||||||
}: {
|
}: {
|
||||||
active?: "home" | "projects";
|
active?: "home" | "projects" | "brandKit";
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
onProjects: () => void;
|
onProjects: () => void;
|
||||||
|
onBrandKit: () => void;
|
||||||
onInspiration: () => void;
|
onInspiration: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -187,10 +189,13 @@ export function SideNav({
|
|||||||
<button className={active === "projects" ? "active" : ""} aria-label={t("projects")} onClick={onProjects}>
|
<button className={active === "projects" ? "active" : ""} aria-label={t("projects")} onClick={onProjects}>
|
||||||
<Folder size={22} />
|
<Folder size={22} />
|
||||||
</button>
|
</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} />
|
<User size={22} />
|
||||||
</button>
|
</button>
|
||||||
<button aria-label={t("info")} onClick={onInspiration}>
|
<button className="side-nav-secondary" aria-label={t("info")} onClick={onInspiration}>
|
||||||
<Info size={22} />
|
<Info size={22} />
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</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 }) {
|
export function SectionHeader({ title, action, onAction }: { title: string; action?: string; onAction?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { designGateway } from "@/infrastructure/designGateway";
|
||||||
import { useAuth } from "@/ui/auth/AuthProvider";
|
import { useAuth } from "@/ui/auth/AuthProvider";
|
||||||
import { useGlobalToast } from "@/ui/components/CanvasToast";
|
import { useGlobalToast } from "@/ui/components/CanvasToast";
|
||||||
@@ -64,6 +64,7 @@ export function ProjectsRoutePage() {
|
|||||||
}
|
}
|
||||||
openLogin();
|
openLogin();
|
||||||
}}
|
}}
|
||||||
|
onBrandKit={openBrandKit}
|
||||||
onInspiration={openHome}
|
onInspiration={openHome}
|
||||||
/>
|
/>
|
||||||
<ProjectsPage locked={!isAuthenticated} creating={creating} onCreateProject={createProject} />
|
<ProjectsPage locked={!isAuthenticated} creating={creating} onCreateProject={createProject} />
|
||||||
|
|||||||
@@ -1291,6 +1291,9 @@ button:disabled {
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
max-width: calc(100% - 220px);
|
||||||
|
min-width: 0;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3943,9 +3946,14 @@ button:disabled {
|
|||||||
.workspace-stage-title {
|
.workspace-stage-title {
|
||||||
left: 6px;
|
left: 6px;
|
||||||
right: 132px;
|
right: 132px;
|
||||||
|
max-width: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-stage-title .brand-kit-selector-trigger.is-compact {
|
||||||
|
max-width: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
.canvas-open-account-controls {
|
.canvas-open-account-controls {
|
||||||
right: 8px;
|
right: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user