diff --git a/frontend/src/application/route.ts b/frontend/src/application/route.ts index 7de805b..c86238a 100644 --- a/frontend/src/application/route.ts +++ b/frontend/src/application/route.ts @@ -1,6 +1,7 @@ export type AppRoute = | { name: "home" } | { name: "projects" } + | { name: "brandKit" } | { name: "project"; id: string } | { name: "share"; id: string } | { name: "notFound" }; @@ -23,6 +24,9 @@ function routeFromPath(path: string): AppRoute | null { if (normalized === "/projects") { return { name: "projects" }; } + if (normalized === "/brand-kit") { + return { name: "brandKit" }; + } const match = normalized.match(/^\/projects?\/([^/]+)$/); if (match) { return { name: "project", id: decodeURIComponent(match[1]) }; @@ -57,6 +61,11 @@ export function openProjects() { window.location.hash = "/projects"; } +export function openBrandKit() { + if (typeof window === "undefined") return; + window.location.hash = "/brand-kit"; +} + export function sharePath(id: string) { return `/share/${encodeURIComponent(id)}`; } diff --git a/frontend/src/domain/brandKit.ts b/frontend/src/domain/brandKit.ts new file mode 100644 index 0000000..54c1074 --- /dev/null +++ b/frontend/src/domain/brandKit.ts @@ -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; + const voice = candidate.voice; + const visual = candidate.visual; + const legacyColors = (candidate as Partial & { 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 { + 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 = { + 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; +} diff --git a/frontend/src/domain/design.ts b/frontend/src/domain/design.ts index 95cc851..a94b9d0 100644 --- a/frontend/src/domain/design.ts +++ b/frontend/src/domain/design.ts @@ -71,6 +71,7 @@ export type ProjectSummary = { brief: string; status: string; thumbnail: string; + brandKitId?: string; updatedAt: string; }; diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 5e2942b..ce14cd1 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -80,6 +80,194 @@ "likes": "likes", "home": "Home", "projects": "Projects", + "brandKit": "Brand Kit", + "brandKitAbout": "About Brand Kits", + "brandKitAddColor": "Add color", + "brandKitAddColorGroup": "Add color group", + "brandKitAddFirstColor": "Add first color", + "brandKitAddTypography": "Add type group", + "brandKitApplied": "Brand Kit applied to this canvas", + "brandKitApplyNewProjects": "Apply to new projects", + "brandKitBindingError": "The Brand Kit could not be applied to this canvas.", + "brandKitAssetsDescription": "Keep logos, a library cover, and visual references together as reusable source material for generation.", + "brandKitAssetsNav": "Assets", + "brandKitAssetsTitle": "Assets", + "brandKitAudience": "Core audience", + "brandKitAudiencePlaceholder": "Who are they, what situation are they in, and what matters most?", + "brandKitBoardMessage": "Ideas, kept in motion.", + "brandKitBoardType": "Brand type", + "brandKitBrandName": "Brand name", + "brandKitBrandNamePlaceholder": "For example: Moteva", + "brandKitCancel": "Cancel", + "brandKitColorDescription": "Give every color a role so generated work uses the palette intentionally.", + "brandKitColorCount": "{count} colors", + "brandKitColorGroupName": "Color group name", + "brandKitColorGroupNamePlaceholder": "For example: Primary", + "brandKitColorHex": "Hex color", + "brandKitColorName": "Color name", + "brandKitColorNav": "Color", + "brandKitColorPicker": "Choose color", + "brandKitColorTitle": "Color system", + "brandKitColorUsage": "Color usage", + "brandKitColorUsagePlaceholder": "Primary text, background, or key action", + "brandKitCleared": "Brand Kit removed from this canvas", + "brandKitCompletion": "{count}% complete", + "brandKitComposition": "Composition principles", + "brandKitCompositionPlaceholder": "Describe visual focus, grid, negative space, and hierarchy.", + "brandKitCopySuffix": "Copy", + "brandKitCreateProject": "Create project with this kit", + "brandKitCreateRequirement": "Add a brand name, summary, and audience or positioning before creating a project", + "brandKitCreate": "Create Brand Kit", + "brandKitCreatingProject": "Creating project...", + "brandKitCoverDescription": "The identifying image used for this Brand Kit in the library.", + "brandKitCoverReadError": "This cover image could not be read.", + "brandKitCoverReady": "Brand Kit cover", + "brandKitCoverSizeError": "Cover images must be under 1 MB.", + "brandKitCoverTitle": "Cover image", + "brandKitCoverTypeError": "Upload a PNG, JPG, WebP, or SVG cover.", + "brandKitCoverUploaded": "Cover image added", + "brandKitDefault": "Default", + "brandKitDelete": "Delete", + "brandKitDeleteDescription": "“{name}” will be removed from your account. Canvases using it will switch to no Brand Kit.", + "brandKitDeleteTitle": "Delete this Brand Kit?", + "brandKitDeleted": "Brand Kit deleted", + "brandKitDonts": "Avoid", + "brandKitDontsPlaceholder": "List colors, compositions, imagery, or language that weaken recognition.", + "brandKitDos": "Use", + "brandKitDosPlaceholder": "List the visual habits and brand moves that must stay consistent.", + "brandKitDuplicate": "Duplicate", + "brandKitDuplicated": "Copy created", + "brandKitEditorEyebrow": "Brand system", + "brandKitEmptyDescription": "Keep positioning, voice, logos, colors, and type in one place so every generation carries the same brand decisions forward.", + "brandKitEmptyShort": "No Brand Kits yet", + "brandKitEmptyTitle": "Make every generation feel like the same brand", + "brandKitExampleBadge": "Example", + "brandKitExampleDescription": "See how a complete set of brand inputs guides both visual and verbal expression.", + "brandKitExampleEyebrow": "Moteva example kit", + "brandKitExampleFooter": "The example is not saved to your library", + "brandKitExampleTemplateName": "Moteva Brand Template", + "brandKitFontFamily": "Font family", + "brandKitFontDescription": "Description", + "brandKitFontDescriptionPlaceholder": "Optional: hierarchy, context, and usage limits", + "brandKitFontReadError": "This font file could not be read.", + "brandKitFontSizeError": "Font files must be under 1 MB.", + "brandKitFontSizes": "Sizes", + "brandKitFontSizesPlaceholder": "Optional: 12 / 16 / 24", + "brandKitFontTypeError": "Upload a WOFF, WOFF2, TTF, or OTF font file.", + "brandKitFontUploaded": "Font uploaded and applied to the preview", + "brandKitFontUsage": "Font usage", + "brandKitFontUsagePlaceholder": "Hierarchy, sizes, and intended contexts", + "brandKitFontWeight": "Weight", + "brandKitFoundationDescription": "State who the brand is and the value it provides in the fewest useful words.", + "brandKitFoundationNav": "Foundation", + "brandKitFoundationTitle": "Brand foundation", + "brandKitImagery": "Image direction", + "brandKitImageryPlaceholder": "Describe subject, light, background, material, and finishing.", + "brandKitLibraryTitle": "My Brand Kits", + "brandKitLoadError": "Your Brand Kits could not be loaded.", + "brandKitLoading": "Loading Brand Kits", + "brandKitLogoDescription": "Upload the logo variants you use and define the boundaries for each one.", + "brandKitLogoLimitError": "Each Brand Kit can store up to 4 logos.", + "brandKitLogoMark": "Mark", + "brandKitLogoName": "Logo name", + "brandKitLogoNav": "Logo", + "brandKitLogoPrimary": "Primary", + "brandKitLogoReadError": "This logo file could not be read.", + "brandKitLogoSecondary": "Secondary", + "brandKitLogoSizeError": "Logo files must be under 1 MB.", + "brandKitLogoTitle": "Logo", + "brandKitLogoTypeError": "Upload a PNG, JPG, WebP, or SVG file.", + "brandKitLogoUsage": "Logo usage", + "brandKitLogoUsagePlaceholder": "Backgrounds, clear space, and prohibited uses", + "brandKitLogoVariant": "Logo variant", + "brandKitMoreActions": "More Brand Kit actions", + "brandKitNameLabel": "Brand Kit name", + "brandKitNew": "New", + "brandKitNewColorGroup": "New color group", + "brandKitNewTypography": "New type group", + "brandKitNone": "None", + "brandKitNoneDescription": "Use only this canvas and prompt", + "brandKitOptional": "Optional", + "brandKitPersonality": "Brand personality", + "brandKitPersonalityPlaceholder": "For example: clear, perceptive, restrained, collaborative", + "brandKitPositioning": "Positioning", + "brandKitPositioningPlaceholder": "How should people understand this brand relative to alternatives?", + "brandKitPreviewLabel": "Moteva Brand Kit preview", + "brandKitProjectInstruction": "Create an editable brand visual project from the system above. Establish one clear direction first and keep the key brand decisions visible on the canvas.", + "brandKitProjectTitle": "{name} Brand Project", + "brandKitPrompt": "Reusable generation direction", + "brandKitPromptPlaceholder": "Turn the most important brand constraints into a direction ready for generation.", + "brandKitReady": "readiness", + "brandKitReferenceDescription": "Upload visual samples that express the brand's composition, photography, material, or mood, then note what to carry forward.", + "brandKitReferenceGuidance": "Reference guidance", + "brandKitReferenceGuidancePlaceholder": "Optional: note the composition, color, texture, or treatment to inherit.", + "brandKitReferenceLimitError": "Each Brand Kit can store up to 6 reference images.", + "brandKitReferenceName": "Reference name", + "brandKitReferenceNamePlaceholder": "For example: Product photography direction", + "brandKitReferenceReadError": "This reference image could not be read.", + "brandKitReferenceSizeError": "Reference images must be under 1 MB.", + "brandKitReferenceTitle": "Reference images", + "brandKitReferenceTypeError": "Upload a PNG, JPG, WebP, or SVG reference image.", + "brandKitReferenceUploaded": "Reference image added", + "brandKitRemoveColor": "Remove color", + "brandKitRemoveColorGroup": "Remove color group", + "brandKitRemoveCover": "Remove cover image", + "brandKitRemoveFont": "Remove font file", + "brandKitRemoveLogo": "Remove logo", + "brandKitRemoveReference": "Remove reference image", + "brandKitRemoveTypography": "Remove type group", + "brandKitReplaceCover": "Replace cover", + "brandKitReplaceFont": "Replace", + "brandKitSaveError": "The Brand Kit could not be saved to your account.", + "brandKitSaveFailed": "Save failed", + "brandKitSaved": "Saved", + "brandKitSaving": "Saving", + "brandKitSectionNavLabel": "Brand Kit sections", + "brandKitSelect": "Choose Brand Kit", + "brandKitStartDescription": "Capture the decisions that shape creative work first, then add the visual assets.", + "brandKitStartTitle": "Make the brand decisions explicit", + "brandKitStrategyDescription": "Define who you are designing for and the position the brand should own.", + "brandKitStrategyNav": "Audience & position", + "brandKitStrategyTitle": "Audience and positioning", + "brandKitSummary": "Brand summary", + "brandKitSummaryPlaceholder": "Describe the brand, product, and core value in one or two sentences.", + "brandKitTagline": "Tagline", + "brandKitTaglinePlaceholder": "A short line that can represent the brand", + "brandKitTemplateAdded": "Example added to your Brand Kit library", + "brandKitTypographyAccent": "Accent type", + "brandKitTypographyBody": "Body type", + "brandKitTypographyDescription": "Upload brand fonts, create custom groups, and optionally define weight, sizes, and usage guidance.", + "brandKitTypographyHeading": "Heading type", + "brandKitTypographyNav": "Typography", + "brandKitTypographyTitle": "Typography system", + "brandKitTypographyName": "Type group name", + "brandKitTypographyNamePlaceholder": "For example: Heading type", + "brandKitUntitled": "Untitled Brand Kit", + "brandKitUnnamedBrand": "Brand name not set", + "brandKitUploadLogo": "Upload logo", + "brandKitUploadLogoHint": "PNG, JPG, WebP, or SVG up to 1 MB", + "brandKitUploadCover": "Upload cover image", + "brandKitUploadCoverHint": "Optional, 16:9 recommended, up to 1 MB", + "brandKitUploadFont": "Upload font", + "brandKitUploadFontHint": "WOFF / WOFF2 / TTF / OTF up to 1 MB", + "brandKitUploadReference": "Upload reference image", + "brandKitUploadReferenceHint": "Optional, PNG, JPG, WebP, or SVG up to 1 MB", + "brandKitUseTemplate": "Use this template", + "brandKitViewExample": "View example", + "brandKitVisualDescription": "Translate an abstract tone into reusable composition, image, and generation rules.", + "brandKitVisualKeywords": "Style keywords", + "brandKitVisualKeywordsPlaceholder": "For example: precise, natural, restrained, energetic", + "brandKitVisualNav": "Visual direction", + "brandKitVisualTitle": "Visual direction", + "brandKitVoiceDescription": "Define how the brand speaks and provide one line that makes the voice concrete.", + "brandKitVoiceGuidance": "Voice guidance", + "brandKitVoiceGuidancePlaceholder": "Describe sentence shape, vocabulary, information order, and language to avoid.", + "brandKitVoiceNav": "Voice", + "brandKitVoiceSample": "Sample line", + "brandKitVoiceSamplePlaceholder": "Write one line that sounds unmistakably like this brand.", + "brandKitVoiceTitle": "Brand voice", + "brandKitVoiceTraits": "Voice traits", + "brandKitVoiceTraitsPlaceholder": "For example: direct, never abrupt; expert, never showy", "personalProjects": "Personal Projects", "projectLibrarySubtitle": "Recent projects only shows three. Manage every personal canvas here.", "editProjects": "Edit", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 46fa680..113743f 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -80,6 +80,194 @@ "likes": "喜欢", "home": "首页", "projects": "项目", + "brandKit": "品牌套件", + "brandKitAbout": "关于品牌套件", + "brandKitAddColor": "添加颜色", + "brandKitAddColorGroup": "添加颜色栏目", + "brandKitAddFirstColor": "添加第一个颜色", + "brandKitAddTypography": "添加字体栏目", + "brandKitApplied": "品牌套件已应用到当前画布", + "brandKitApplyNewProjects": "应用到新项目", + "brandKitBindingError": "无法将品牌套件应用到当前画布。", + "brandKitAssetsDescription": "集中管理品牌 Logo、封面图与视觉参考,生成时作为可复用的素材依据。", + "brandKitAssetsNav": "素材", + "brandKitAssetsTitle": "素材", + "brandKitAudience": "核心受众", + "brandKitAudiencePlaceholder": "他们是谁、处于什么场景、最在意什么?", + "brandKitBoardMessage": "Ideas, kept in motion.", + "brandKitBoardType": "品牌字体", + "brandKitBrandName": "品牌名称", + "brandKitBrandNamePlaceholder": "例如:Moteva", + "brandKitCancel": "取消", + "brandKitColorDescription": "为每个颜色写明角色,避免生成结果随意使用品牌色。", + "brandKitColorCount": "{count} 个颜色", + "brandKitColorGroupName": "颜色栏目名称", + "brandKitColorGroupNamePlaceholder": "例如:主色", + "brandKitColorHex": "十六进制色值", + "brandKitColorName": "颜色名称", + "brandKitColorNav": "颜色", + "brandKitColorPicker": "选择颜色", + "brandKitColorTitle": "颜色系统", + "brandKitColorUsage": "颜色用途", + "brandKitColorUsagePlaceholder": "用于主文字、背景或关键动作", + "brandKitCleared": "已移除当前画布的品牌套件", + "brandKitCompletion": "{count}% 完成", + "brandKitComposition": "构图原则", + "brandKitCompositionPlaceholder": "说明视觉重心、网格、留白与信息层级。", + "brandKitCopySuffix": "副本", + "brandKitCreateProject": "使用此套件创建项目", + "brandKitCreateRequirement": "补充品牌名称、简介以及受众或定位后即可创建项目", + "brandKitCreate": "创建品牌套件", + "brandKitCreatingProject": "正在创建项目...", + "brandKitCoverDescription": "作为品牌套件在列表中的识别画面。", + "brandKitCoverReadError": "无法读取这张封面图。", + "brandKitCoverReady": "品牌套件封面", + "brandKitCoverSizeError": "封面图不能超过 1 MB。", + "brandKitCoverTitle": "封面图", + "brandKitCoverTypeError": "请上传 PNG、JPG、WebP 或 SVG 封面图。", + "brandKitCoverUploaded": "封面图已添加", + "brandKitDefault": "默认", + "brandKitDelete": "删除", + "brandKitDeleteDescription": "“{name}”将从你的账户中移除,使用它的画布会切换为不使用品牌套件。", + "brandKitDeleteTitle": "删除这个品牌套件?", + "brandKitDeleted": "品牌套件已删除", + "brandKitDonts": "避免事项", + "brandKitDontsPlaceholder": "列出会削弱品牌辨识度的颜色、构图、图像或表达。", + "brandKitDos": "推荐做法", + "brandKitDosPlaceholder": "列出必须保留的视觉习惯与品牌动作。", + "brandKitDuplicate": "创建副本", + "brandKitDuplicated": "副本已创建", + "brandKitEditorEyebrow": "品牌系统", + "brandKitEmptyDescription": "集中管理品牌定位、语气、Logo、颜色与字体,让每次生成都延续同一套品牌判断。", + "brandKitEmptyShort": "还没有品牌套件", + "brandKitEmptyTitle": "让每次生成都像同一个品牌", + "brandKitExampleBadge": "示例", + "brandKitExampleDescription": "查看一套完整品牌信息如何共同约束视觉与文字表达。", + "brandKitExampleEyebrow": "Moteva 示例套件", + "brandKitExampleFooter": "示例不会写入你的品牌库", + "brandKitExampleTemplateName": "Moteva 品牌模板", + "brandKitFontFamily": "字体名称", + "brandKitFontDescription": "字体说明", + "brandKitFontDescriptionPlaceholder": "可选:适用层级、语境与使用限制", + "brandKitFontReadError": "无法读取这个字体文件。", + "brandKitFontSizeError": "字体文件不能超过 1 MB。", + "brandKitFontSizes": "字号", + "brandKitFontSizesPlaceholder": "可选:12 / 16 / 24", + "brandKitFontTypeError": "请上传 WOFF、WOFF2、TTF 或 OTF 字体文件。", + "brandKitFontUploaded": "字体已上传并应用到预览", + "brandKitFontUsage": "字体用途", + "brandKitFontUsagePlaceholder": "适用层级、字号与使用场景", + "brandKitFontWeight": "字重", + "brandKitFoundationDescription": "用最短的文字说明品牌是谁、提供什么价值。", + "brandKitFoundationNav": "品牌基础", + "brandKitFoundationTitle": "品牌基础", + "brandKitImagery": "图像风格", + "brandKitImageryPlaceholder": "描述主体、光线、背景、材质和后期处理方式。", + "brandKitLibraryTitle": "我的品牌套件", + "brandKitLoadError": "无法加载你的品牌套件。", + "brandKitLoading": "正在加载品牌套件", + "brandKitLogoDescription": "上传常用标志版本,并明确每个版本的使用边界。", + "brandKitLogoLimitError": "每个品牌套件最多保存 4 个 Logo。", + "brandKitLogoMark": "图形标", + "brandKitLogoName": "Logo 名称", + "brandKitLogoNav": "Logo", + "brandKitLogoPrimary": "主标志", + "brandKitLogoReadError": "无法读取这个 Logo 文件。", + "brandKitLogoSecondary": "辅助标志", + "brandKitLogoSizeError": "Logo 文件不能超过 1 MB。", + "brandKitLogoTitle": "Logo", + "brandKitLogoTypeError": "请上传 PNG、JPG、WebP 或 SVG 文件。", + "brandKitLogoUsage": "Logo 用途", + "brandKitLogoUsagePlaceholder": "适用背景、安全空间与禁用情况", + "brandKitLogoVariant": "Logo 类型", + "brandKitMoreActions": "更多品牌套件操作", + "brandKitNameLabel": "品牌套件名称", + "brandKitNew": "新建", + "brandKitNewColorGroup": "新颜色栏目", + "brandKitNewTypography": "新字体栏目", + "brandKitNone": "不使用", + "brandKitNoneDescription": "仅使用当前画布与输入内容", + "brandKitOptional": "非必须", + "brandKitPersonality": "品牌个性", + "brandKitPersonalityPlaceholder": "例如:清晰、敏锐、克制、协作", + "brandKitPositioning": "品牌定位", + "brandKitPositioningPlaceholder": "与替代方案相比,你希望用户如何理解这个品牌?", + "brandKitPreviewLabel": "Moteva 品牌套件预览", + "brandKitProjectInstruction": "基于以上品牌系统创建一个可继续编辑的品牌视觉项目。先给出一个明确主方向,并把关键品牌判断保留在画布中。", + "brandKitProjectTitle": "{name} 品牌项目", + "brandKitPrompt": "一句话生成指令", + "brandKitPromptPlaceholder": "把最重要的品牌约束整理成一段可直接用于生成的指令。", + "brandKitReady": "就绪度", + "brandKitReferenceDescription": "上传能够代表品牌构图、摄影、材质或氛围的视觉样本,并说明借鉴重点。", + "brandKitReferenceGuidance": "参考说明", + "brandKitReferenceGuidancePlaceholder": "可选:说明需要继承的构图、色彩、质感或处理方式。", + "brandKitReferenceLimitError": "每个品牌套件最多保存 6 张参考图。", + "brandKitReferenceName": "参考图名称", + "brandKitReferenceNamePlaceholder": "例如:产品摄影方向", + "brandKitReferenceReadError": "无法读取这张参考图。", + "brandKitReferenceSizeError": "参考图不能超过 1 MB。", + "brandKitReferenceTitle": "参考图", + "brandKitReferenceTypeError": "请上传 PNG、JPG、WebP 或 SVG 参考图。", + "brandKitReferenceUploaded": "参考图已添加", + "brandKitRemoveColor": "移除颜色", + "brandKitRemoveColorGroup": "移除颜色栏目", + "brandKitRemoveCover": "移除封面图", + "brandKitRemoveFont": "移除字体文件", + "brandKitRemoveLogo": "移除 Logo", + "brandKitRemoveReference": "移除参考图", + "brandKitRemoveTypography": "移除字体栏目", + "brandKitReplaceCover": "更换封面", + "brandKitReplaceFont": "更换", + "brandKitSaveError": "无法将品牌套件保存到你的账户。", + "brandKitSaveFailed": "保存失败", + "brandKitSaved": "已保存", + "brandKitSaving": "保存中", + "brandKitSectionNavLabel": "品牌套件章节", + "brandKitSelect": "选择品牌套件", + "brandKitStartDescription": "先写下真正影响创作判断的信息,再补充视觉资产。", + "brandKitStartTitle": "先把品牌判断写清楚", + "brandKitStrategyDescription": "界定你在为谁设计,以及品牌要占据的位置。", + "brandKitStrategyNav": "受众与定位", + "brandKitStrategyTitle": "受众与定位", + "brandKitSummary": "品牌简介", + "brandKitSummaryPlaceholder": "用 1-2 句话描述品牌、产品与核心价值。", + "brandKitTagline": "品牌标语", + "brandKitTaglinePlaceholder": "一句可以代表品牌的短句", + "brandKitTemplateAdded": "示例已添加到你的品牌库", + "brandKitTypographyAccent": "强调字体", + "brandKitTypographyBody": "正文字体", + "brandKitTypographyDescription": "上传品牌字体,自定义栏目,并按需补充字重、字号和使用说明。", + "brandKitTypographyHeading": "标题字体", + "brandKitTypographyNav": "字体", + "brandKitTypographyTitle": "字体系统", + "brandKitTypographyName": "字体栏目名称", + "brandKitTypographyNamePlaceholder": "例如:标题字体", + "brandKitUntitled": "未命名品牌套件", + "brandKitUnnamedBrand": "尚未填写品牌名称", + "brandKitUploadLogo": "上传 Logo", + "brandKitUploadLogoHint": "PNG、JPG、WebP 或 SVG,不超过 1 MB", + "brandKitUploadCover": "上传封面图", + "brandKitUploadCoverHint": "可选,推荐 16:9,不超过 1 MB", + "brandKitUploadFont": "上传字体", + "brandKitUploadFontHint": "WOFF / WOFF2 / TTF / OTF,不超过 1 MB", + "brandKitUploadReference": "上传参考图", + "brandKitUploadReferenceHint": "可选,PNG、JPG、WebP 或 SVG,不超过 1 MB", + "brandKitUseTemplate": "使用此模板", + "brandKitViewExample": "查看示例", + "brandKitVisualDescription": "把抽象调性翻译成可复用的构图、图像与生成规则。", + "brandKitVisualKeywords": "风格关键词", + "brandKitVisualKeywordsPlaceholder": "例如:精准、自然、克制、富有张力", + "brandKitVisualNav": "视觉方向", + "brandKitVisualTitle": "视觉方向", + "brandKitVoiceDescription": "规定品牌如何说话,并给出一条能直接对照的示例。", + "brandKitVoiceGuidance": "表达准则", + "brandKitVoiceGuidancePlaceholder": "说明句式、用词、信息顺序以及需要避免的表达。", + "brandKitVoiceNav": "品牌语气", + "brandKitVoiceSample": "示例文案", + "brandKitVoiceSamplePlaceholder": "写一句最像这个品牌会说的话。", + "brandKitVoiceTitle": "品牌语气", + "brandKitVoiceTraits": "语气特征", + "brandKitVoiceTraitsPlaceholder": "例如:直接但不生硬;专业但不炫技", "personalProjects": "个人项目", "projectLibrarySubtitle": "最近项目只保留 3 个,这里管理全部个人画布。", "editProjects": "编辑", diff --git a/frontend/src/infrastructure/brandKitRepository.ts b/frontend/src/infrastructure/brandKitRepository.ts new file mode 100644 index 0000000..e7aca49 --- /dev/null +++ b/frontend/src/infrastructure/brandKitRepository.ts @@ -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>(); + +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(`/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("/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"); +} diff --git a/frontend/src/infrastructure/designGateway.ts b/frontend/src/infrastructure/designGateway.ts index ee764b6..b9f3460 100644 --- a/frontend/src/infrastructure/designGateway.ts +++ b/frontend/src/infrastructure/designGateway.ts @@ -13,6 +13,7 @@ export const designGateway = { createProjectAsync: projectOperations.createProjectAsync, getProject: projectOperations.getProject, updateProjectTitle: projectOperations.updateProjectTitle, + updateProjectBrandKit: projectOperations.updateProjectBrandKit, deleteProject: projectOperations.deleteProject, deleteProjects: projectOperations.deleteProjects, deleteAsset: assetOperations.deleteAsset, diff --git a/frontend/src/infrastructure/designGateway/canvasSnapshot.ts b/frontend/src/infrastructure/designGateway/canvasSnapshot.ts index 761395c..f3e443a 100644 --- a/frontend/src/infrastructure/designGateway/canvasSnapshot.ts +++ b/frontend/src/infrastructure/designGateway/canvasSnapshot.ts @@ -33,6 +33,7 @@ export async function projectFromQueryProjectResponse(response: QueryProjectResp snapshotId: response.data.snapshotId || snapshot.snapshotId || "", canvas: response.data.canvas, lastThreadId: "", + brandKitId: response.data.brandKitId || undefined, viewport, nodes, connections: normalizeCanvasConnections(snapshot.connections, nodes), @@ -110,6 +111,7 @@ export function mergeSavedCanvasProject( snapshotId: draft.snapshotId, canvas: canvas || current?.canvas || "", lastThreadId: current?.lastThreadId ?? "", + brandKitId: current?.brandKitId, viewport, nodes, connections, diff --git a/frontend/src/infrastructure/designGateway/projects.ts b/frontend/src/infrastructure/designGateway/projects.ts index 76cd24c..3591499 100644 --- a/frontend/src/infrastructure/designGateway/projects.ts +++ b/frontend/src/infrastructure/designGateway/projects.ts @@ -55,6 +55,13 @@ function updateProjectTitle(id: string, title: string) { }); } +function updateProjectBrandKit(id: string, brandKitId: string) { + return request(`/api/projects/${id}/brand-kit`, { + method: "PATCH", + body: JSON.stringify({ brandKitId }) + }); +} + function deleteProject(id: string) { return request(`/api/projects/${id}`, { method: "DELETE" @@ -79,6 +86,7 @@ export const projectOperations = { createProjectAsync, getProject, updateProjectTitle, + updateProjectBrandKit, deleteProject, deleteProjects }; diff --git a/frontend/src/infrastructure/designGateway/responses.ts b/frontend/src/infrastructure/designGateway/responses.ts index 24b4d9c..28e2a50 100644 --- a/frontend/src/infrastructure/designGateway/responses.ts +++ b/frontend/src/infrastructure/designGateway/responses.ts @@ -60,6 +60,7 @@ export type QueryProjectResponse = { projectType: number; version: string; snapshotId: string | null; + brandKitId?: string; permission: string; }; }; diff --git a/frontend/src/infrastructure/designGateway/types.ts b/frontend/src/infrastructure/designGateway/types.ts index d637ba0..e7121ea 100644 --- a/frontend/src/infrastructure/designGateway/types.ts +++ b/frontend/src/infrastructure/designGateway/types.ts @@ -1,6 +1,7 @@ import type { AgentMessage, CanvasConnection, CanvasNode, CanvasViewport, ExtractNodeTextResponse, Project } from "@/domain/design"; export type ImageGenerationOptions = { + brandKitId?: string; imageModel?: string; imageSize?: string; imageQuality?: string; diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 8232125..7fe2dcb 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -7,6 +7,7 @@ import { readRoute, type AppRoute } from "@/application/route"; import { designGateway } from "@/infrastructure/designGateway"; import { httpStatusOf } from "@/infrastructure/userMessages"; import { BrandMark } from "@/ui/components/BrandMark"; +import { BrandKitRoutePage } from "@/ui/pages/BrandKitRoutePage"; import { CanvasWorkspace } from "@/ui/pages/CanvasWorkspace"; import { HomePage } from "@/ui/pages/HomePage"; import { NotFoundPage } from "@/ui/pages/NotFoundPage"; @@ -46,6 +47,7 @@ export function App() { if (route.name === "project") return ; if (route.name === "share") return ; if (route.name === "projects") return ; + if (route.name === "brandKit") return ; if (route.name === "notFound") return ; return ; } diff --git a/frontend/src/ui/components/BrandKitSelector/index.css b/frontend/src/ui/components/BrandKitSelector/index.css new file mode 100644 index 0000000..a6ca9c2 --- /dev/null +++ b/frontend/src/ui/components/BrandKitSelector/index.css @@ -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; +} diff --git a/frontend/src/ui/components/BrandKitSelector/index.tsx b/frontend/src/ui/components/BrandKitSelector/index.tsx new file mode 100644 index 0000000..d52c69b --- /dev/null +++ b/frontend/src/ui/components/BrandKitSelector/index.tsx @@ -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; + 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 ( + + + + + +
+ {t("brandKitSelect")} + {selected?.applyToNewProjects && {t("brandKitDefault")}} +
+ {loading ? ( +
+ +
+ ) : kits.length === 0 ? ( +
+ + {t("brandKitEmptyShort")} + +
+ ) : ( +
+ + {kits.map((kit) => { + const colors = brandKitColors(kit).slice(0, 4); + const active = selectedId === kit.id; + return ( + + ); + })} +
+ )} + {kits.length > 0 && !loading && ( + + )} +
+
+ ); +} diff --git a/frontend/src/ui/pages/BrandKitPage/index.css b/frontend/src/ui/pages/BrandKitPage/index.css new file mode 100644 index 0000000..760757e --- /dev/null +++ b/frontend/src/ui/pages/BrandKitPage/index.css @@ -0,0 +1,1919 @@ +.brand-kit-empty-page, +.brand-kit-page { + min-height: 100dvh; + padding: 64px 0 0 104px; + color: #17191d; + background: #fff; +} + +.brand-kit-route-shell.is-auth-locked .brand-kit-empty-page, +.brand-kit-route-shell.is-auth-locked .brand-kit-page { + filter: blur(18px); + pointer-events: none; + user-select: none; + transform: scale(0.985); + transition: filter 180ms ease, transform 180ms ease; +} + +.brand-kit-empty-page { + display: grid; + place-items: center; + padding-right: 32px; + padding-bottom: 84px; +} + +.brand-kit-empty-content { + width: min(520px, 100%); + display: flex; + flex-direction: column; + align-items: center; +} + +.brand-kit-board { + width: 100%; + aspect-ratio: 16 / 9; + display: grid; + grid-template-columns: 0.92fr 1fr 1.4fr; + grid-template-rows: 1fr 34px 0.52fr; + gap: 7px; + padding: 16px; + overflow: hidden; + border: 1px solid #e9eaec; + border-radius: 8px; + color: #0b0b0c; + background: #f4f4f2; + box-shadow: 0 18px 52px rgba(19, 23, 31, 0.08); +} + +.brand-kit-board-mark, +.brand-kit-board-type, +.brand-kit-board-message, +.brand-kit-board-colors, +.brand-kit-board-principle { + min-width: 0; + overflow: hidden; + border: 1px solid rgba(11, 11, 12, 0.08); + border-radius: 5px; + background: #fff; +} + +.brand-kit-board-mark { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 14px; +} + +.brand-kit-board-mark svg { + width: 28px; + height: 36px; +} + +.brand-kit-board-mark strong { + font-size: 14px; + line-height: 1; +} + +.brand-kit-board-type { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 13px; + background: #ddfb70; +} + +.brand-kit-board-type span { + font-size: 56px; + line-height: 0.9; + font-weight: 500; +} + +.brand-kit-board-type small { + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.brand-kit-board-message { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 14px; + color: #fff; + background: #0b0b0c; +} + +.brand-kit-board-message span { + max-width: 110px; + font-size: 18px; + line-height: 1.12; + font-weight: 720; +} + +.brand-kit-board-message b { + width: 24px; + height: 24px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 50%; + color: #0b0b0c; + background: #ddfb70; + font-size: 10px; +} + +.brand-kit-board-colors { + grid-column: 1 / 3; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 3px; + padding: 4px; +} + +.brand-kit-board-colors i { + display: block; + min-width: 0; +} + +.brand-kit-board-principle { + grid-column: 1 / 4; + display: flex; + align-items: flex-end; + padding: 12px 14px; + color: #5c626d; + background: #e8e9e5; + font-size: 10px; + line-height: 1; + font-weight: 850; +} + +.brand-kit-empty-copy { + margin-top: 38px; + text-align: center; +} + +.brand-kit-empty-copy h1 { + margin: 0; + color: #111214; + font-size: 25px; + line-height: 1.25; + letter-spacing: 0; +} + +.brand-kit-empty-copy p { + margin: 10px auto 0; + color: #6f747e; + font-size: 14px; + line-height: 1.65; + font-weight: 540; +} + +.brand-kit-empty-actions { + display: flex; + align-items: center; + gap: 9px; + margin-top: 32px; +} + +.brand-kit-primary-action, +.brand-kit-secondary-action, +.brand-kit-danger-action { + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 14px; + border-radius: 7px; + font-size: 13px; + line-height: 1; + font-weight: 750; + white-space: nowrap; + transition: color 150ms ease, background 150ms ease, border-color 150ms ease, transform 150ms ease; +} + +.brand-kit-primary-action { + color: #fff; + background: #111214; +} + +.brand-kit-primary-action:hover:not(:disabled) { + background: #2a2c30; + transform: translateY(-1px); +} + +.brand-kit-secondary-action { + border: 1px solid #dfe1e5; + color: #22252a; + background: #fff; +} + +.brand-kit-secondary-action:hover:not(:disabled) { + border-color: #c6c9cf; + background: #f5f6f7; +} + +.brand-kit-danger-action { + color: #fff; + background: #b42318; +} + +.brand-kit-page { + height: 100dvh; + display: flex; + overflow: hidden; +} + +.brand-kit-library { + width: 244px; + min-width: 244px; + height: 100%; + display: flex; + flex-direction: column; + border-right: 1px solid #e8e9eb; + background: #fafafa; +} + +.brand-kit-library-header { + padding: 18px 14px 14px; + border-bottom: 1px solid #eceef0; +} + +.brand-kit-library-header > div { + display: flex; + align-items: center; + gap: 7px; + min-height: 28px; +} + +.brand-kit-library-header strong { + font-size: 13px; + line-height: 1; +} + +.brand-kit-library-header span, +.brand-kit-example-badge { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 6px; + border-radius: 4px; + color: #2e5a00; + background: #e8f7b7; + font-size: 10px; + font-style: normal; + font-weight: 800; +} + +.brand-kit-library-header > div > button { + width: 28px; + height: 28px; + display: grid; + place-items: center; + margin-left: auto; + border-radius: 6px; + color: #8b9099; + background: transparent; +} + +.brand-kit-library-header > div > button:hover { + color: #17191d; + background: #f0f1f2; +} + +.brand-kit-library-create { + width: 100%; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + margin-top: 10px; + border: 1px solid #dfe1e5; + border-radius: 7px; + color: #22252a; + background: #fff; + font-size: 13px; + font-weight: 750; +} + +.brand-kit-library-create:hover { + border-color: #c7cad0; + background: #f4f5f6; +} + +.brand-kit-library-list { + min-height: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 5px; + padding: 10px 8px 24px; + overflow-y: auto; +} + +.brand-kit-library-item { + width: 100%; + display: flex; + flex-direction: column; + padding: 7px; + border: 1px solid transparent; + border-radius: 8px; + color: #17191d; + background: transparent; + text-align: left; +} + +.brand-kit-library-item:hover { + background: #f0f1f2; +} + +.brand-kit-library-item.active { + border-color: #dfe1e5; + background: #fff; + box-shadow: 0 4px 12px rgba(20, 24, 32, 0.05); +} + +.brand-kit-library-preview { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid #e4e6e9; + border-radius: 6px; + background: #f1f2ef; +} + +.brand-kit-library-cover { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.brand-kit-library-logo { + position: relative; + z-index: 1; + width: 48px; + height: 42px; + display: grid; + place-items: center; +} + +.brand-kit-library-logo svg, +.brand-kit-library-logo img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.brand-kit-library-swatches { + position: absolute; + right: 6px; + bottom: 6px; + left: 6px; + height: 8px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 2px; + overflow: hidden; + border-radius: 3px; + z-index: 2; +} + +.brand-kit-library-meta { + min-width: 0; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8px; + padding: 9px 2px 6px; +} + +.brand-kit-library-meta > span { + min-width: 0; + display: flex; + align-items: center; + gap: 5px; +} + +.brand-kit-library-meta strong { + min-width: 0; + overflow: hidden; + color: #202226; + font-size: 12px; + font-weight: 720; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brand-kit-library-meta em { + flex: 0 0 auto; + padding: 2px 4px; + border-radius: 3px; + color: #385a00; + background: #e8f7b7; + font-size: 8px; + font-style: normal; + font-weight: 800; +} + +.brand-kit-library-meta small { + flex: 0 0 auto; + color: #9297a0; + font-size: 9px; +} + +.brand-kit-library-progress { + height: 2px; + overflow: hidden; + border-radius: 2px; + background: #e5e7ea; +} + +.brand-kit-library-progress i { + height: 100%; + display: block; + background: #24272d; +} + +.brand-kit-library-example { + min-height: 42px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px dashed #d7dade; + border-radius: 7px; + color: #5f6570; + background: #fff; + font-size: 12px; + font-weight: 700; +} + +.brand-kit-editor-shell { + min-width: 0; + height: 100%; + flex: 1; + display: flex; + flex-direction: column; + background: #fff; +} + +.brand-kit-editor-header { + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 0 28px; + border-bottom: 1px solid #eceef0; + background: rgba(255, 255, 255, 0.96); +} + +.brand-kit-title-area, +.brand-kit-header-actions, +.brand-kit-project-default { + display: flex; + align-items: center; +} + +.brand-kit-title-area { + min-width: 0; + gap: 9px; +} + +.brand-kit-title-area > input { + width: min(360px, 34vw); + min-width: 120px; + padding: 5px 7px; + border: 1px solid transparent; + border-radius: 6px; + outline: none; + color: #15171a; + background: transparent; + font-size: 19px; + line-height: 1.25; + font-weight: 760; + text-overflow: ellipsis; +} + +.brand-kit-title-area > input:not(:read-only):hover, +.brand-kit-title-area > input:not(:read-only):focus { + border-color: #dfe1e5; + background: #f8f8f8; +} + +.brand-kit-header-actions { + flex: 0 0 auto; + gap: 10px; +} + +.brand-kit-project-default { + gap: 9px; + color: #5f6570; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.brand-kit-icon-action { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 7px; + color: #5f6570; + background: transparent; +} + +.brand-kit-icon-action:hover { + color: #17191d; + background: #f1f2f3; +} + +.brand-kit-save-state { + min-width: 68px; + display: inline-flex; + align-items: center; + gap: 4px; + color: #8a9099; + font-size: 10px; + font-weight: 700; +} + +.brand-kit-save-state.error { + color: #b42318; +} + +.brand-kit-editor-scroll { + min-height: 0; + flex: 1; + overflow-y: auto; + scroll-behavior: smooth; +} + +.brand-kit-editor-content { + width: min(1080px, 100%); + margin: 0 auto; + padding: 40px 44px 72px; +} + +.brand-kit-progress-overview { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 36px; + padding: 4px 0 36px; + border-bottom: 1px solid #e7e9ec; +} + +.brand-kit-progress-overview > div:first-child { + max-width: 690px; +} + +.brand-kit-progress-overview > div:first-child > span { + color: #7a808a; + font-size: 10px; + line-height: 1; + font-weight: 850; + text-transform: uppercase; +} + +.brand-kit-progress-overview h1 { + margin: 10px 0 0; + color: #121417; + font-size: 28px; + line-height: 1.2; + letter-spacing: 0; +} + +.brand-kit-progress-overview p { + margin: 9px 0 0; + color: #747a84; + font-size: 13px; + line-height: 1.6; +} + +.brand-kit-progress-value { + width: 142px; + display: grid; + grid-template-columns: auto 1fr; + align-items: baseline; + gap: 4px 7px; +} + +.brand-kit-progress-value strong { + color: #191b1f; + font-size: 20px; +} + +.brand-kit-progress-value span { + color: #8a9099; + font-size: 10px; + font-weight: 700; +} + +.brand-kit-progress-value > i { + grid-column: 1 / 3; + height: 4px; + overflow: hidden; + border-radius: 3px; + background: #e8eaed; +} + +.brand-kit-progress-value > i > b { + height: 100%; + display: block; + background: #ddfb70; +} + +.brand-kit-editor-layout { + display: grid; + grid-template-columns: 152px minmax(0, 1fr); + gap: 54px; +} + +.brand-kit-section-nav { + position: sticky; + top: 0; + align-self: start; + display: flex; + flex-direction: column; + gap: 2px; + padding-top: 36px; +} + +.brand-kit-section-nav button { + min-height: 32px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 8px; + border-radius: 6px; + color: #676d77; + background: transparent; + font-size: 11px; + font-weight: 650; + text-align: left; +} + +.brand-kit-section-nav button:hover { + color: #17191d; + background: #f3f4f5; +} + +.brand-kit-section-nav button span { + width: 19px; + color: #a1a6ae; + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +.brand-kit-form { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.brand-kit-form:disabled { + opacity: 1; +} + +.brand-kit-section { + scroll-margin-top: 22px; + padding: 38px 0 42px; + border-bottom: 1px solid #e7e9ec; +} + +.brand-kit-section > header { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 11px; + margin-bottom: 26px; +} + +.brand-kit-section > header > span { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 1px solid #e0e2e5; + border-radius: 7px; + color: #30343a; + background: #f8f8f7; +} + +.brand-kit-section h2, +.brand-kit-section p { + margin: 0; +} + +.brand-kit-section h2 { + color: #1b1d21; + font-size: 16px; + line-height: 1.25; +} + +.brand-kit-section p { + margin-top: 5px; + color: #858b95; + font-size: 11px; + line-height: 1.55; +} + +.brand-kit-section-fields { + display: flex; + flex-direction: column; + gap: 20px; +} + +.brand-kit-asset-section { + padding: 0 0 28px; + border-bottom: 1px solid #eceef0; +} + +.brand-kit-asset-section + .brand-kit-asset-section { + padding-top: 28px; +} + +.brand-kit-asset-section:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.brand-kit-asset-section > header { + margin-bottom: 16px; +} + +.brand-kit-asset-section > header > div { + display: flex; + align-items: center; + gap: 7px; +} + +.brand-kit-asset-section h3, +.brand-kit-asset-section p { + margin: 0; +} + +.brand-kit-asset-section h3 { + color: #292c31; + font-size: 12px; +} + +.brand-kit-asset-section > header span { + padding: 2px 5px; + border-radius: 4px; + color: #7c828c; + background: #eff0f2; + font-size: 9px; + font-weight: 750; +} + +.brand-kit-asset-section > header p { + margin-top: 5px; + color: #90959e; + font-size: 10px; + line-height: 1.5; +} + +.brand-kit-field-grid { + display: grid; + gap: 16px; +} + +.brand-kit-field-grid.two-column { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.brand-kit-field { + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.brand-kit-field > span { + color: #4b5059; + font-size: 11px; + line-height: 1; + font-weight: 750; +} + +.brand-kit-field input, +.brand-kit-field textarea, +.brand-kit-logo-fields input, +.brand-kit-logo-fields select, +.brand-kit-color-row input:not([type="color"]), +.brand-kit-type-fields input { + width: 100%; + border: 1px solid #dfe2e6; + border-radius: 6px; + outline: none; + color: #202329; + background: #fff; + font-size: 12px; + line-height: 1.5; + transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; +} + +.brand-kit-type-label, +.brand-kit-color-group > header > input, +.brand-kit-reference-item input, +.brand-kit-reference-item textarea { + border: 1px solid #dfe2e6; + border-radius: 6px; + outline: none; + color: #202329; + background: #fff; + font: inherit; +} + +.brand-kit-field input, +.brand-kit-logo-fields input, +.brand-kit-logo-fields select, +.brand-kit-color-row input:not([type="color"]), +.brand-kit-type-fields input { + height: 40px; + padding: 0 11px; +} + +.brand-kit-field textarea { + min-height: 92px; + padding: 10px 11px; + resize: vertical; +} + +.brand-kit-field input::placeholder, +.brand-kit-field textarea::placeholder, +.brand-kit-logo-fields input::placeholder, +.brand-kit-color-row input::placeholder, +.brand-kit-type-fields input::placeholder { + color: #b1b5bc; +} + +.brand-kit-field input:focus, +.brand-kit-field textarea:focus, +.brand-kit-logo-fields input:focus, +.brand-kit-logo-fields select:focus, +.brand-kit-color-row input:focus, +.brand-kit-type-fields input:focus { + border-color: #7b8492; + box-shadow: 0 0 0 3px rgba(83, 93, 108, 0.1); +} + +.brand-kit-type-label:focus, +.brand-kit-color-group > header > input:focus, +.brand-kit-reference-item input:focus, +.brand-kit-reference-item textarea:focus { + border-color: #7b8492; + box-shadow: 0 0 0 3px rgba(83, 93, 108, 0.1); +} + +.brand-kit-form:disabled input, +.brand-kit-form:disabled textarea, +.brand-kit-form:disabled select { + color: #373b42; + background: #f8f8f7; + opacity: 1; + -webkit-text-fill-color: #373b42; +} + +.brand-kit-logo-grid { + display: grid; + gap: 10px; +} + +.brand-kit-logo-item { + position: relative; + min-width: 0; + display: grid; + grid-template-columns: 112px minmax(0, 1fr) 30px; + gap: 12px; + align-items: center; + padding: 10px; + border: 1px solid #e0e2e5; + border-radius: 8px; + background: #fff; +} + +.brand-kit-logo-preview { + height: 84px; + display: grid; + place-items: center; + padding: 15px; + overflow: hidden; + border-radius: 6px; + background: #f2f3f1; +} + +.brand-kit-logo-preview svg, +.brand-kit-logo-preview img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.brand-kit-logo-fields { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 116px; + gap: 8px; +} + +.brand-kit-logo-fields input:last-child { + grid-column: 1 / 3; +} + +.brand-kit-logo-fields select { + appearance: auto; +} + +.brand-kit-row-remove { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 6px; + color: #969ba4; + background: transparent; +} + +.brand-kit-row-remove:hover { + color: #b42318; + background: #fff1ef; +} + +.brand-kit-add-asset { + min-height: 112px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px dashed #cfd3d9; + border-radius: 8px; + color: #373b42; + background: #fbfbfa; +} + +.brand-kit-add-asset > span { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 50%; + color: #24272d; + background: #eef0f2; +} + +.brand-kit-add-asset strong { + font-size: 12px; +} + +.brand-kit-add-asset small { + color: #999ea7; + font-size: 10px; +} + +.brand-kit-add-asset:hover { + border-color: #959ba5; + background: #f7f8f8; +} + +.brand-kit-color-list, +.brand-kit-color-editor, +.brand-kit-color-groups, +.brand-kit-type-list { + display: flex; + flex-direction: column; + gap: 9px; +} + +.brand-kit-color-groups { + gap: 14px; +} + +.brand-kit-color-group { + padding: 12px; + border: 1px solid #e0e2e5; + border-radius: 8px; + background: #fff; +} + +.brand-kit-color-group > header { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.brand-kit-color-group > header > input { + min-width: 100px; + width: 180px; + height: 34px; + padding: 0 9px; + font-size: 12px; + font-weight: 760; +} + +.brand-kit-color-group > header > span { + color: #969ba4; + font-size: 10px; + white-space: nowrap; +} + +.brand-kit-color-group > header > button:not(.brand-kit-row-remove) { + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + padding: 0 9px; + border: 1px solid #dfe2e6; + border-radius: 6px; + color: #353941; + background: #fff; + font-size: 10px; + font-weight: 720; +} + +.brand-kit-color-group > header > button:not(.brand-kit-row-remove):hover { + background: #f4f5f6; +} + +.brand-kit-color-row { + display: grid; + grid-template-columns: 40px 86px minmax(100px, 0.75fr) minmax(160px, 1.5fr) 30px; + gap: 8px; + align-items: center; +} + +.brand-kit-empty-color-group { + min-height: 42px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px dashed #d5d8dd; + border-radius: 6px; + color: #777d87; + background: #fafaf9; + font-size: 10px; + font-weight: 700; +} + +.brand-kit-color-picker { + width: 40px; + height: 40px; + padding: 3px; + overflow: hidden; + border: 1px solid #dfe2e6; + border-radius: 6px; + background: #fff; +} + +.brand-kit-color-hex { + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 10px !important; + text-transform: uppercase; +} + +.brand-kit-add-row { + align-self: flex-start; + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 11px; + border: 1px solid #dfe2e6; + border-radius: 6px; + color: #32363d; + background: #fff; + font-size: 11px; + font-weight: 720; +} + +.brand-kit-add-row:hover { + background: #f4f5f6; +} + +.brand-kit-type-row { + min-width: 0; + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 13px; + align-items: center; + padding: 10px; + border: 1px solid #e0e2e5; + border-radius: 8px; + background: #fff; +} + +.brand-kit-type-sample { + height: 66px; + display: grid; + place-items: center; + border-radius: 6px; + color: #1b1d21; + background: #f1f2f0; + font-size: 25px; + font-weight: 650; +} + +.brand-kit-type-row > div { + min-width: 0; +} + +.brand-kit-type-heading { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.brand-kit-type-label { + min-width: 110px; + width: 150px; + height: 32px; + padding: 0 8px; + font-size: 11px; + font-weight: 760; +} + +.brand-kit-font-upload { + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 9px; + border: 1px dashed #cfd3d9; + border-radius: 6px; + color: #4c525c; + background: #fafaf9; + font-size: 10px; + font-weight: 720; +} + +.brand-kit-font-upload small { + color: #989da6; + font-size: 8px; + font-weight: 600; +} + +.brand-kit-font-file { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; + padding: 4px 6px 4px 8px; + border-radius: 6px; + color: #343840; + background: #f0f2ed; +} + +.brand-kit-font-file > span { + min-width: 0; + display: flex; + flex-direction: column; +} + +.brand-kit-font-file b { + max-width: 150px; + overflow: hidden; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brand-kit-font-file small { + color: #858b95; + font-size: 8px; +} + +.brand-kit-font-file button { + flex: 0 0 auto; + padding: 4px; + border-radius: 4px; + color: #646a74; + background: transparent; + font-size: 9px; +} + +.brand-kit-font-file button:hover { + color: #17191d; + background: rgba(255, 255, 255, 0.8); +} + +.brand-kit-type-remove { + flex: 0 0 auto; + margin-left: auto; +} + +.brand-kit-type-fields { + min-width: 0; + display: grid; + grid-template-columns: minmax(100px, 1fr) 82px minmax(110px, 0.7fr); + gap: 8px; +} + +.brand-kit-type-fields input:last-child { + grid-column: 1 / 4; +} + +.brand-kit-reference-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.brand-kit-reference-item { + position: relative; + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + border: 1px solid #e0e2e5; + border-radius: 8px; + background: #fff; +} + +.brand-kit-reference-preview, +.brand-kit-cover-preview { + width: 100%; + aspect-ratio: 16 / 9; + display: grid; + place-items: center; + overflow: hidden; + border-radius: 6px; + background: #f0f1ef; +} + +.brand-kit-reference-preview > img, +.brand-kit-cover-preview > img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.brand-kit-reference-built-in { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: 1fr auto; + align-items: start; + padding: 14px; + color: #fff; + background: #111214; +} + +.brand-kit-reference-built-in svg { + width: 30px; + height: 40px; + color: #ddfb70; +} + +.brand-kit-reference-built-in strong { + max-width: 92px; + font-size: 12px; + line-height: 1.15; + text-align: right; +} + +.brand-kit-reference-built-in > span { + grid-column: 1 / 3; + height: 9px; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 3px; +} + +.brand-kit-reference-built-in i:nth-child(1) { + background: #ddfb70; +} + +.brand-kit-reference-built-in i:nth-child(2) { + background: #f7f7f5; +} + +.brand-kit-reference-built-in i:nth-child(3) { + background: #2f66f6; +} + +.brand-kit-reference-item label { + display: flex; + flex-direction: column; + gap: 6px; +} + +.brand-kit-reference-item label > span { + color: #555b65; + font-size: 10px; + font-weight: 720; +} + +.brand-kit-reference-item input, +.brand-kit-reference-item textarea { + width: 100%; + padding: 9px 10px; + font-size: 11px; + line-height: 1.5; +} + +.brand-kit-reference-item textarea { + min-height: 70px; + resize: vertical; +} + +.brand-kit-reference-remove { + position: absolute; + top: 16px; + right: 16px; + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 6px; + color: #fff; + background: rgba(16, 18, 21, 0.68); + backdrop-filter: blur(6px); +} + +.brand-kit-add-reference, +.brand-kit-add-cover { + min-height: 150px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px dashed #cfd3d9; + border-radius: 8px; + color: #373b42; + background: #fbfbfa; +} + +.brand-kit-add-reference > span, +.brand-kit-add-cover > span { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 50%; + background: #eef0f2; +} + +.brand-kit-add-reference strong, +.brand-kit-add-cover strong { + font-size: 11px; +} + +.brand-kit-add-reference small, +.brand-kit-add-cover small { + color: #999ea7; + font-size: 9px; +} + +.brand-kit-cover-filled { + min-width: 0; + display: grid; + grid-template-columns: 180px minmax(0, 1fr) auto 30px; + align-items: center; + gap: 12px; + padding: 10px; + border: 1px solid #e0e2e5; + border-radius: 8px; +} + +.brand-kit-cover-filled > div:nth-child(2) { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.brand-kit-cover-filled > div:nth-child(2) strong { + overflow: hidden; + color: #30343a; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brand-kit-cover-filled > div:nth-child(2) span { + color: #9297a0; + font-size: 9px; +} + +.brand-kit-cover-filled > button { + min-height: 30px; + padding: 0 9px; + border-radius: 6px; + color: #555b65; + background: #f1f2f3; + font-size: 10px; + font-weight: 700; +} + +.brand-kit-editor-footer { + min-height: 66px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; + padding: 0 28px; + border-top: 1px solid #e7e9ec; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 -10px 24px rgba(17, 24, 39, 0.035); +} + +.brand-kit-editor-footer > span { + color: #8a9099; + font-size: 10px; + font-weight: 650; +} + +.brand-kit-actions-menu { + min-width: 184px; + padding: 5px; + border-radius: 8px; +} + +.brand-kit-actions-menu .ui-dropdown-menu-item { + min-height: 34px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 9px; + border-radius: 5px; + color: #33373e; + font-size: 12px; + cursor: pointer; +} + +.brand-kit-actions-menu .ui-dropdown-menu-item:hover, +.brand-kit-actions-menu .ui-dropdown-menu-item[data-highlighted] { + background: #f1f2f3; +} + +.brand-kit-actions-menu .ui-dropdown-menu-item.danger { + color: #b42318; +} + +.brand-kit-actions-menu .ui-dropdown-menu-separator { + height: 1px; + margin: 4px; + background: #e8eaed; +} + +.brand-kit-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 120; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15, 17, 20, 0.42); +} + +.brand-kit-delete-dialog { + position: relative; + width: min(400px, 100%); + padding: 28px; + border: 1px solid rgba(17, 24, 39, 0.08); + border-radius: 8px; + background: #fff; + box-shadow: 0 28px 70px rgba(10, 13, 18, 0.22); +} + +.brand-kit-dialog-close { + position: absolute; + top: 14px; + right: 14px; + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 6px; + color: #737984; + background: transparent; +} + +.brand-kit-dialog-close:hover { + background: #f1f2f3; +} + +.brand-kit-delete-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 7px; + color: #b42318; + background: #fff1ef; +} + +.brand-kit-delete-dialog h2 { + margin: 18px 0 0; + color: #191b1f; + font-size: 19px; +} + +.brand-kit-delete-dialog p { + margin: 9px 0 0; + color: #737984; + font-size: 12px; + line-height: 1.6; +} + +.brand-kit-delete-dialog > div:last-child { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 24px; +} + +.brand-kit-loading { + position: fixed; + top: 50%; + left: 50%; + width: 22px; + height: 22px; + border: 2px solid #dfe2e6; + border-top-color: #33373e; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@media (max-width: 1180px) { + .brand-kit-editor-content { + padding-right: 30px; + padding-left: 30px; + } + + .brand-kit-editor-layout { + grid-template-columns: 1fr; + gap: 0; + } + + .brand-kit-section-nav { + position: sticky; + top: 0; + z-index: 4; + flex-direction: row; + gap: 3px; + padding: 10px 0; + overflow-x: auto; + border-bottom: 1px solid #e8eaed; + background: rgba(255, 255, 255, 0.96); + scrollbar-width: none; + } + + .brand-kit-section-nav::-webkit-scrollbar { + display: none; + } + + .brand-kit-section-nav button { + flex: 0 0 auto; + } +} + +@media (max-width: 980px) { + .brand-kit-empty-page, + .brand-kit-page { + padding-left: 0; + padding-bottom: 74px; + } + + .brand-kit-empty-page { + padding-right: 24px; + padding-left: 24px; + } + + .brand-kit-library { + width: 220px; + min-width: 220px; + } +} + +@media (max-width: 820px) { + .brand-kit-page { + flex-direction: column; + } + + .brand-kit-library { + width: 100%; + min-width: 0; + height: auto; + flex: 0 0 auto; + border-right: 0; + border-bottom: 1px solid #e8e9eb; + } + + .brand-kit-library-header { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + } + + .brand-kit-library-header > div { + flex: 1; + } + + .brand-kit-library-create { + width: auto; + flex: 0 0 auto; + margin-top: 0; + padding: 0 12px; + } + + .brand-kit-library-list { + flex-direction: row; + padding: 7px 10px 9px; + overflow-x: auto; + overflow-y: hidden; + } + + .brand-kit-library-item { + width: 176px; + min-width: 176px; + display: grid; + grid-template-columns: 52px minmax(0, 1fr); + align-items: center; + column-gap: 8px; + } + + .brand-kit-library-preview { + width: 52px; + aspect-ratio: 1; + grid-row: 1 / 3; + } + + .brand-kit-library-logo { + width: 24px; + height: 24px; + } + + .brand-kit-library-swatches { + right: 3px; + bottom: 3px; + left: 3px; + height: 5px; + } + + .brand-kit-library-meta { + display: block; + padding: 0; + } + + .brand-kit-library-meta > span { + align-items: flex-start; + flex-direction: column; + gap: 3px; + } + + .brand-kit-library-meta small { + display: block; + margin-top: 5px; + } + + .brand-kit-library-progress { + align-self: end; + } + + .brand-kit-library-example { + min-width: 150px; + } + + .brand-kit-editor-shell { + min-height: 0; + } +} + +@media (max-width: 640px) { + .brand-kit-empty-page { + align-items: start; + padding-top: 98px; + } + + .brand-kit-board { + padding: 10px; + gap: 5px; + } + + .brand-kit-board-mark, + .brand-kit-board-type, + .brand-kit-board-message { + padding: 9px; + } + + .brand-kit-board-message span { + font-size: 13px; + } + + .brand-kit-board-type span { + font-size: 34px; + } + + .brand-kit-empty-copy { + margin-top: 28px; + } + + .brand-kit-empty-copy h1 { + font-size: 22px; + } + + .brand-kit-editor-header { + min-height: 88px; + align-items: flex-start; + flex-direction: column; + gap: 7px; + padding: 12px 16px; + } + + .brand-kit-title-area, + .brand-kit-header-actions { + width: 100%; + } + + .brand-kit-title-area > input { + width: 100%; + font-size: 17px; + } + + .brand-kit-header-actions { + justify-content: space-between; + } + + .brand-kit-editor-content { + padding: 28px 18px 58px; + } + + .brand-kit-progress-overview { + align-items: flex-start; + flex-direction: column; + gap: 22px; + padding-bottom: 28px; + } + + .brand-kit-progress-overview h1 { + font-size: 23px; + } + + .brand-kit-progress-value { + width: 100%; + } + + .brand-kit-field-grid.two-column { + grid-template-columns: 1fr; + } + + .brand-kit-section { + padding: 30px 0 34px; + } + + .brand-kit-logo-item { + grid-template-columns: 82px minmax(0, 1fr) 28px; + gap: 8px; + } + + .brand-kit-logo-preview { + height: 88px; + } + + .brand-kit-logo-fields { + grid-template-columns: 1fr; + } + + .brand-kit-logo-fields input:last-child { + grid-column: 1; + } + + .brand-kit-color-row { + grid-template-columns: 40px minmax(0, 1fr) 30px; + } + + .brand-kit-color-row > input:nth-of-type(3), + .brand-kit-color-row > input:nth-of-type(4) { + grid-column: 1 / 4; + } + + .brand-kit-color-row > .brand-kit-row-remove { + grid-column: 3; + grid-row: 1; + } + + .brand-kit-color-group > header { + flex-wrap: wrap; + } + + .brand-kit-color-group > header > input { + flex: 1; + } + + .brand-kit-color-group > header > button:not(.brand-kit-row-remove) { + margin-left: 0; + } + + .brand-kit-type-row { + grid-template-columns: 54px minmax(0, 1fr); + align-items: start; + } + + .brand-kit-type-sample { + height: 54px; + font-size: 20px; + } + + .brand-kit-type-fields { + grid-template-columns: 1fr 76px; + } + + .brand-kit-type-fields input:nth-child(3), + .brand-kit-type-fields input:last-child { + grid-column: 1 / 3; + } + + .brand-kit-type-heading { + align-items: flex-start; + flex-wrap: wrap; + } + + .brand-kit-type-label { + width: calc(100% - 38px); + } + + .brand-kit-font-upload, + .brand-kit-font-file { + order: 3; + width: 100%; + } + + .brand-kit-font-upload small { + display: none; + } + + .brand-kit-type-remove { + position: absolute; + top: 10px; + right: 10px; + } + + .brand-kit-type-row { + position: relative; + } + + .brand-kit-reference-grid { + grid-template-columns: 1fr; + } + + .brand-kit-cover-filled { + grid-template-columns: 112px minmax(0, 1fr) 30px; + } + + .brand-kit-cover-filled > button:nth-last-child(2) { + grid-column: 1 / 4; + grid-row: 2; + } + + .brand-kit-cover-filled > button:last-child { + grid-column: 3; + grid-row: 1; + } + + .brand-kit-editor-footer { + min-height: 64px; + justify-content: space-between; + padding: 0 14px; + } + + .brand-kit-editor-footer .brand-kit-primary-action { + min-width: 0; + padding: 0 11px; + } +} + +@media (max-width: 390px) { + .brand-kit-editor-footer > span { + display: none; + } + + .brand-kit-empty-actions { + width: 100%; + } + + .brand-kit-empty-actions > button { + flex: 1; + } +} diff --git a/frontend/src/ui/pages/BrandKitPage/index.tsx b/frontend/src/ui/pages/BrandKitPage/index.tsx new file mode 100644 index 0000000..ed98bdb --- /dev/null +++ b/frontend/src/ui/pages/BrandKitPage/index.tsx @@ -0,0 +1,1271 @@ +"use client"; + +import { + ArrowRight, + Check, + CircleHelp, + Compass, + Copy, + Image as ImageIcon, + ImagePlus, + MessageSquareText, + MoreHorizontal, + Palette, + Plus, + Shapes, + Sparkles, + Trash2, + Type, + Upload, + X +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + brandKitColors, + brandKitCompletion, + cloneBrandKit, + createBrandKit, + createBrandKitColor, + createBrandKitColorGroup, + createBrandKitFontFile, + createBrandKitLogo, + createBrandKitReferenceImage, + createBrandKitTypography, + createMotevaBrandKitExample, + type BrandKit, + type BrandKitColorGroup, + type BrandKitLogo, + type BrandKitReferenceImage, + type BrandKitTypography +} from "@/domain/brandKit"; +import { brandKitRepository } from "@/infrastructure/brandKitRepository"; +import { useI18n } from "@/i18n/i18n"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { Switch } from "@/components/ui/switch"; +import { BrandMark } from "@/ui/components/BrandMark"; +import { useGlobalToast } from "@/ui/components/CanvasToast"; +import "./index.css"; + +type BrandKitPageProps = { + storageScope: string | null; + creatingProject: boolean; + onCreateProject: (kit: BrandKit) => void; +}; + +type EditorMode = "stored" | "example"; +type SaveState = "saved" | "saving" | "error"; + +const maxLogoBytes = 1024 * 1024; +const maxFontBytes = 1024 * 1024; +const supportedLogoTypes = new Set(["image/png", "image/jpeg", "image/webp", "image/svg+xml"]); +const supportedFontName = /\.(woff2?|ttf|otf)$/i; + +export function BrandKitPage({ storageScope, creatingProject, onCreateProject }: BrandKitPageProps) { + const { locale, t } = useI18n(); + const { showError, showSuccess } = useGlobalToast(); + const exampleKit = useMemo(() => createMotevaBrandKitExample(locale), [locale]); + const [loaded, setLoaded] = useState(false); + const [kits, setKits] = useState([]); + const [draft, setDraft] = useState(null); + const [mode, setMode] = useState("stored"); + const [dirty, setDirty] = useState(false); + const [saveState, setSaveState] = useState("saved"); + const [pendingDelete, setPendingDelete] = useState(null); + const saveSequenceRef = useRef(0); + + useEffect(() => { + let active = true; + saveSequenceRef.current += 1; + setLoaded(false); + brandKitRepository + .list(storageScope) + .then((stored) => { + if (!active) return; + setKits(stored); + setDraft(stored[0] ?? null); + setMode("stored"); + setDirty(false); + setSaveState("saved"); + }) + .catch(() => { + if (!active) return; + setKits([]); + setDraft(null); + setSaveState("error"); + showError(t("brandKitLoadError")); + }) + .finally(() => { + if (active) setLoaded(true); + }); + return () => { + active = false; + }; + }, [showError, storageScope, t]); + + useEffect(() => { + if (mode === "example") setDraft(exampleKit); + }, [exampleKit, mode]); + + useEffect(() => { + if (!loaded || mode === "example" || !draft || !dirty) return; + setSaveState("saving"); + const saveSequence = ++saveSequenceRef.current; + const candidate = draft; + const timer = window.setTimeout(() => { + void brandKitRepository + .save(storageScope, candidate) + .then((saved) => { + if (saveSequence !== saveSequenceRef.current) return; + setKits((current) => mergeKit(current, saved)); + setDraft((current) => + current?.id === saved.id + ? { ...current, applyToNewProjects: saved.applyToNewProjects, createdAt: saved.createdAt, updatedAt: saved.updatedAt } + : current + ); + setDirty(false); + setSaveState("saved"); + }) + .catch(() => { + if (saveSequence !== saveSequenceRef.current) return; + setSaveState("error"); + showError(t("brandKitSaveError")); + }); + }, 480); + return () => window.clearTimeout(timer); + }, [dirty, draft, loaded, mode, showError, storageScope, t]); + + const persistDraft = async (candidate: BrandKit | null) => { + if (!candidate || mode === "example") return candidate; + saveSequenceRef.current += 1; + try { + const saved = await brandKitRepository.save(storageScope, candidate); + setKits((current) => mergeKit(current, saved)); + setDirty(false); + setSaveState("saved"); + return saved; + } catch { + setSaveState("error"); + showError(t("brandKitSaveError")); + return candidate; + } + }; + + const updateDraft = (update: (current: BrandKit) => BrandKit) => { + if (mode === "example") return; + setDraft((current) => (current ? update(current) : current)); + setDirty(true); + setSaveState("saving"); + }; + + const setDefaultKit = async (checked: boolean) => { + if (!draft || mode === "example") return; + saveSequenceRef.current += 1; + setSaveState("saving"); + try { + const saved = await brandKitRepository.save(storageScope, { ...draft, applyToNewProjects: checked }); + const updated = await brandKitRepository.list(storageScope); + setKits(updated); + setDraft(updated.find((kit) => kit.id === saved.id) ?? saved); + setDirty(false); + setSaveState("saved"); + } catch { + setSaveState("error"); + showError(t("brandKitSaveError")); + } + }; + + const storeCreatedKit = async (kit: BrandKit, successMessage?: string) => { + saveSequenceRef.current += 1; + setSaveState("saving"); + try { + const created = await brandKitRepository.save(storageScope, kit); + setKits((current) => mergeKit(current, created)); + setDraft(created); + setMode("stored"); + setDirty(false); + setSaveState("saved"); + if (successMessage) showSuccess(successMessage); + return created; + } catch { + setSaveState("error"); + showError(t("brandKitSaveError")); + return null; + } + }; + + const createNewKit = async () => { + await persistDraft(draft); + await storeCreatedKit(createBrandKit(t("brandKitUntitled"), locale)); + }; + + const viewExample = async () => { + await persistDraft(draft); + setMode("example"); + setDraft(exampleKit); + setDirty(false); + setSaveState("saved"); + }; + + const useExampleTemplate = async () => { + await storeCreatedKit(cloneBrandKit(exampleKit, t("brandKitExampleTemplateName")), t("brandKitTemplateAdded")); + }; + + const selectKit = async (kit: BrandKit) => { + await persistDraft(draft); + setDraft(kit); + setMode("stored"); + setDirty(false); + setSaveState("saved"); + }; + + const duplicateDraft = async () => { + if (!draft || mode === "example") return; + const source = (await persistDraft(draft)) ?? draft; + await storeCreatedKit(cloneBrandKit(source, `${source.name} ${t("brandKitCopySuffix")}`), t("brandKitDuplicated")); + }; + + const deleteDraft = async () => { + if (!pendingDelete) return; + saveSequenceRef.current += 1; + try { + const remaining = await brandKitRepository.remove(storageScope, pendingDelete.id); + setKits(remaining); + setDraft(remaining[0] ?? null); + setPendingDelete(null); + setMode("stored"); + setDirty(false); + setSaveState("saved"); + showSuccess(t("brandKitDeleted")); + } catch { + setSaveState("error"); + showError(t("brandKitSaveError")); + } + }; + + const uploadLogo = async (file: File | undefined) => { + if (!file || !draft || mode === "example") return; + if (!supportedLogoTypes.has(file.type)) { + showError(t("brandKitLogoTypeError")); + return; + } + if (file.size > maxLogoBytes) { + showError(t("brandKitLogoSizeError")); + return; + } + if (draft.logos.length >= 4) { + showError(t("brandKitLogoLimitError")); + return; + } + try { + const dataUrl = await readFileAsDataUrl(file); + updateDraft((current) => ({ ...current, logos: [...current.logos, createBrandKitLogo(file, dataUrl)] })); + } catch { + showError(t("brandKitLogoReadError")); + } + }; + + const uploadFont = async (itemId: string, file: File | undefined) => { + if (!file || !draft || mode === "example") return; + if (!supportedFontName.test(file.name)) { + showError(t("brandKitFontTypeError")); + return; + } + if (file.size > maxFontBytes) { + showError(t("brandKitFontSizeError")); + return; + } + try { + const dataUrl = await readFileAsDataUrl(file); + const fontFile = createBrandKitFontFile(file, dataUrl); + updateDraft((current) => ({ + ...current, + typography: current.typography.map((item) => + item.id === itemId + ? { + ...item, + family: item.family || fontFamilyFromFileName(file.name), + fontFile + } + : item + ) + })); + showSuccess(t("brandKitFontUploaded")); + } catch { + showError(t("brandKitFontReadError")); + } + }; + + const uploadReferenceImage = async (file: File | undefined) => { + if (!file || !draft || mode === "example") return; + if (!supportedLogoTypes.has(file.type)) { + showError(t("brandKitReferenceTypeError")); + return; + } + if (file.size > maxLogoBytes) { + showError(t("brandKitReferenceSizeError")); + return; + } + if (draft.referenceImages.length >= 6) { + showError(t("brandKitReferenceLimitError")); + return; + } + try { + const dataUrl = await readFileAsDataUrl(file); + updateDraft((current) => ({ + ...current, + referenceImages: [...current.referenceImages, createBrandKitReferenceImage(file, dataUrl)] + })); + showSuccess(t("brandKitReferenceUploaded")); + } catch { + showError(t("brandKitReferenceReadError")); + } + }; + + const uploadCoverImage = async (file: File | undefined) => { + if (!file || !draft || mode === "example") return; + if (!supportedLogoTypes.has(file.type)) { + showError(t("brandKitCoverTypeError")); + return; + } + if (file.size > maxLogoBytes) { + showError(t("brandKitCoverSizeError")); + return; + } + try { + const dataUrl = await readFileAsDataUrl(file); + updateDraft((current) => ({ + ...current, + coverImage: { + ...createBrandKitReferenceImage(file, dataUrl), + id: `cover-${current.id}` + } + })); + showSuccess(t("brandKitCoverUploaded")); + } catch { + showError(t("brandKitCoverReadError")); + } + }; + + if (!loaded) { + return
; + } + + if (!draft && mode === "stored" && kits.length === 0) { + return ; + } + + if (!draft) return null; + + const readOnly = mode === "example"; + const completion = brandKitCompletion(draft); + const canCreateProject = Boolean(draft.brandName.trim() && draft.summary.trim() && (draft.audience.trim() || draft.positioning.trim())); + + return ( +
+ +
+
+
+ updateDraft((current) => ({ ...current, name: event.target.value }))} + /> + {readOnly ? ( + {t("brandKitExampleBadge")} + ) : ( + + )} +
+
+ {readOnly ? ( + + ) : ( + <> + + + + + + + + + {t("brandKitDuplicate")} + + + setPendingDelete(draft)}> + + {t("brandKitDelete")} + + + + + )} +
+
+ +
+
+ +
+ +
+ } title={t("brandKitFoundationTitle")} description={t("brandKitFoundationDescription")}> +
+ updateDraft((current) => ({ ...current, brandName: value }))} + /> + updateDraft((current) => ({ ...current, tagline: value }))} + /> +
+ updateDraft((current) => ({ ...current, summary: value }))} + /> +
+ + } title={t("brandKitStrategyTitle")} description={t("brandKitStrategyDescription")}> + updateDraft((current) => ({ ...current, audience: value }))} + /> + updateDraft((current) => ({ ...current, positioning: value }))} + /> + updateDraft((current) => ({ ...current, personality: value }))} + /> + + + } title={t("brandKitVoiceTitle")} description={t("brandKitVoiceDescription")}> + updateDraft((current) => ({ ...current, voice: { ...current.voice, traits: value } }))} + /> + updateDraft((current) => ({ ...current, voice: { ...current.voice, guidance: value } }))} + /> + updateDraft((current) => ({ ...current, voice: { ...current.voice, sample: value } }))} + /> + + + } title={t("brandKitAssetsTitle")} description={t("brandKitAssetsDescription")}> + + updateDraft((current) => ({ ...current, logos }))} + /> + + + updateDraft((current) => ({ ...current, coverImage }))} + /> + + + updateDraft((current) => ({ ...current, referenceImages }))} + /> + + + + } title={t("brandKitColorTitle")} description={t("brandKitColorDescription")}> + updateDraft((current) => ({ ...current, colorGroups }))} + /> + + + } title={t("brandKitTypographyTitle")} description={t("brandKitTypographyDescription")}> + updateDraft((current) => ({ ...current, typography }))} + /> + + + } title={t("brandKitVisualTitle")} description={t("brandKitVisualDescription")}> + updateDraft((current) => ({ ...current, visual: { ...current.visual, keywords: value } }))} + /> + updateDraft((current) => ({ ...current, visual: { ...current.visual, composition: value } }))} + /> + updateDraft((current) => ({ ...current, visual: { ...current.visual, imagery: value } }))} + /> +
+ updateDraft((current) => ({ ...current, visual: { ...current.visual, dos: value } }))} + /> + updateDraft((current) => ({ ...current, visual: { ...current.visual, donts: value } }))} + /> +
+ updateDraft((current) => ({ ...current, visual: { ...current.visual, prompt: value } }))} + /> +
+
+
+
+
+ +
+ {readOnly ? t("brandKitExampleFooter") : canCreateProject ? t("brandKitCompletion", { count: completion }) : t("brandKitCreateRequirement")} + +
+
+ + {pendingDelete && ( + setPendingDelete(null)} + onConfirm={deleteDraft} + /> + )} +
+ ); +} + +function BrandKitEmptyState({ onViewExample, onCreate }: { onViewExample: () => void; onCreate: () => void }) { + const { t } = useI18n(); + return ( +
+
+ +
+

{t("brandKitEmptyTitle")}

+

{t("brandKitEmptyDescription")}

+
+
+ + +
+
+
+ ); +} + +function BrandBoard({ kit }: { kit?: BrandKit }) { + const { t } = useI18n(); + const kitColors = kit ? brandKitColors(kit) : []; + const colors = kitColors.length ? kitColors.slice(0, 4).map((color) => validColor(color.hex)) : ["#0B0B0C", "#F7F7F5", "#DDFB70", "#2F66F6"]; + const brandName = kit?.brandName.trim() || "Moteva"; + return ( +
+
+ + {brandName} +
+
+ Aa + {t("brandKitBoardType")} +
+
+ {t("brandKitBoardMessage")} + M +
+
+ {colors.map((color, index) => ( + + ))} +
+
CLARITY / MOTION / CRAFT
+
+ ); +} + +function BrandKitLibrary({ + kits, + selectedId, + exampleActive, + exampleKit, + onCreate, + onSelect, + onViewExample +}: { + kits: BrandKit[]; + selectedId: string; + exampleActive: boolean; + exampleKit: BrandKit; + onCreate: () => void; + onSelect: (kit: BrandKit) => void; + onViewExample: () => void; +}) { + const { t } = useI18n(); + return ( + + ); +} + +function BrandKitLibraryItem({ kit, active, badge, onClick }: { kit: BrandKit; active: boolean; badge?: string; onClick: () => void }) { + const { t } = useI18n(); + const completion = brandKitCompletion(kit); + const kitColors = brandKitColors(kit); + const colors = kitColors.length ? kitColors.slice(0, 4) : [{ id: "empty", hex: "#E8EAED" }]; + const uploadedLogo = kit.logos.find((logo) => logo.source === "upload" && logo.dataUrl); + const uploadedCover = kit.coverImage?.source === "upload" ? kit.coverImage.dataUrl : undefined; + return ( + + ); +} + +function SaveIndicator({ state }: { state: SaveState }) { + const { t } = useI18n(); + return ( + + {state === "saved" && } + {state === "saved" ? t("brandKitSaved") : state === "saving" ? t("brandKitSaving") : t("brandKitSaveFailed")} + + ); +} + +function BrandKitProgress({ kit, completion, readOnly }: { kit: BrandKit; completion: number; readOnly: boolean }) { + const { t } = useI18n(); + return ( +
+
+ {readOnly ? t("brandKitExampleEyebrow") : t("brandKitEditorEyebrow")} +

{kit.brandName.trim() || t("brandKitStartTitle")}

+

{readOnly ? t("brandKitExampleDescription") : t("brandKitStartDescription")}

+
+
+ {completion}% + {t("brandKitReady")} + +
+
+ ); +} + +function BrandKitSectionNav() { + const { t } = useI18n(); + const sections = [ + ["brand-kit-foundation", t("brandKitFoundationNav")], + ["brand-kit-strategy", t("brandKitStrategyNav")], + ["brand-kit-voice", t("brandKitVoiceNav")], + ["brand-kit-assets", t("brandKitAssetsNav")], + ["brand-kit-color", t("brandKitColorNav")], + ["brand-kit-type", t("brandKitTypographyNav")], + ["brand-kit-visual", t("brandKitVisualNav")] + ]; + return ( + + ); +} + +function BrandSection({ id, icon, title, description, children }: { id: string; icon: ReactNode; title: string; description: string; children: ReactNode }) { + return ( +
+
+ {icon} +
+

{title}

+

{description}

+
+
+
{children}
+
+ ); +} + +function AssetSubsection({ + title, + description, + optional = false, + children +}: { + title: string; + description: string; + optional?: boolean; + children: ReactNode; +}) { + const { t } = useI18n(); + return ( +
+
+
+

{title}

+ {optional && {t("brandKitOptional")}} +
+

{description}

+
+ {children} +
+ ); +} + +function TextField({ label, value, placeholder, onChange }: { label: string; value: string; placeholder: string; onChange: (value: string) => void }) { + return ( + + ); +} + +function TextAreaField({ label, value, placeholder, onChange }: { label: string; value: string; placeholder: string; onChange: (value: string) => void }) { + return ( +