From 85abcf7fce8101eee782c2b04736c1746cc18706 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 14:47:48 +0200 Subject: [PATCH] Brand kit: colours, fonts and logos that follow every design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand consistency is the single most-wanted thing around design editors and the one most reliably locked behind a paywall. "canva brand kit" runs ~3.2K US / 5.8K global searches a month — roughly three times any other feature term in this space — and the recurring complaint is not that the feature is missing, it is that the kit is trapped: it cannot be moved to another account, so offboarding means rebuilding it by hand. OpenDesign is self-hosted, so it can simply not do that. This adds: - A Brand section in the left rail: palette, heading/body font, logos. - Brand swatches wherever a colour is chosen (text fill, shape fill, page background), so staying on brand costs one click at the moment of picking. - "Apply to this page", which re-fonts the text and recolours the design in one undoable step. - Export / Import as a plain .brandkit.json file. A kit moves between installs, or to a client, with no rebuild. Recolouring maps by rank in luminance rather than nearest colour: the darkest thing in a design stays the darkest thing in it, so contrast survives a palette swap instead of collapsing a background and its heading onto the same swatch. Kits are a list rather than one settings row because an agency running a single install needs one kit per client. --- .gitignore | 1 + README.md | 1 + agent.md | 3 + src/client/app.tsx | 3 + src/client/components/brand-kit-panel.tsx | 333 ++++++++++++++++++++++ src/client/components/left-sidebar.tsx | 8 +- src/client/components/right-sidebar.tsx | 39 ++- src/client/context.tsx | 12 +- src/client/fonts.ts | 13 + src/client/hooks/use-brand-kits.ts | 68 +++++ src/client/hooks/use-canvas.ts | 47 ++- src/client/lib/brand.ts | 101 +++++++ src/client/lib/kit-transfer.ts | 88 ++++++ src/client/types.ts | 11 + src/server/index.ts | 128 +++++++++ src/server/schema.sql | 13 + 16 files changed, 854 insertions(+), 15 deletions(-) create mode 100644 src/client/components/brand-kit-panel.tsx create mode 100644 src/client/fonts.ts create mode 100644 src/client/hooks/use-brand-kits.ts create mode 100644 src/client/lib/brand.ts create mode 100644 src/client/lib/kit-transfer.ts diff --git a/.gitignore b/.gitignore index e1ac1a95..19f63660 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ data.db-wal data.db-shm uploads/ .env +.wrangler/ diff --git a/README.md b/README.md index ef3a147a..1cf25eb0 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Unlike Canva or Adobe Express, this runs entirely on your own infrastructure. No - **Text editing** — font family, size, weight, alignment, color, line height, letter spacing - **Shapes** — rectangles, circles, triangles, lines with fill, stroke, border radius - **Image uploads** — drag-and-drop or click to upload, place on canvas +- **Brand kits** — save a palette, a heading/body font pair and your logos, then apply them to any design in one click. Brand swatches appear wherever you pick a color. Kits export and import as plain JSON, so a kit moves between installs or over to a client instead of being stranded in one account - **Backgrounds** — solid colors, gradients, uploaded images - **Canvas sizes** — LinkedIn Square (1080x1080), LinkedIn Landscape (1200x627), LinkedIn Portrait (1200x1500), Instagram Story (1080x1920) - **Undo/Redo** — full history with keyboard shortcuts (Cmd+Z / Cmd+Shift+Z) diff --git a/agent.md b/agent.md index a635d6f5..405e070a 100644 --- a/agent.md +++ b/agent.md @@ -6,6 +6,8 @@ A design editor for creating professional social media graphics, especially Link - Fabric.js-based canvas editor with drag-and-drop - Pre-built LinkedIn post templates (Quote Card, Stats Highlight, Announcement, Tips List, Profile Card, Minimal Text) - Text editing with Google Fonts (Inter, Montserrat, Playfair Display) +- Brand kits — palette, heading/body fonts and logos, applied to a design in one + step and portable between installs as JSON (`/api/brand-kits`) - Image uploads and placement - Multiple canvas sizes (1080x1080 square, 1200x627 landscape) - Save and manage multiple designs @@ -16,3 +18,4 @@ Use this template when the user wants to: - Design LinkedIn posts, quote cards, or announcement banners - Build a simple graphic design tool - Create branded visual content +- Keep a team or client on-brand across every graphic (brand kit) diff --git a/src/client/app.tsx b/src/client/app.tsx index 485e1a09..ad1f738b 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -1,6 +1,7 @@ import { EditorContext } from "./context"; import { useCanvasState } from "./hooks/use-canvas"; import { useDesigns } from "./hooks/use-designs"; +import { useBrandKits } from "./hooks/use-brand-kits"; import { useRouter } from "./hooks/use-router"; import { Editor } from "./components/editor"; import { Home } from "./components/home"; @@ -11,6 +12,7 @@ export function App() { const { path, navigate, designId } = useRouter(); const canvasState = useCanvasState(); const designState = useDesigns(canvasState.getCanvasJSONForPage); + const brandState = useBrandKits(); // Load Google Fonts useEffect(() => { @@ -88,6 +90,7 @@ export function App() { const contextValue = { ...canvasState, ...designState, + ...brandState, // activeCanvasId is the source of truth for which page is active activePageId: canvasState.activeCanvasId ?? designState.activePageId, navigate, diff --git a/src/client/components/brand-kit-panel.tsx b/src/client/components/brand-kit-panel.tsx new file mode 100644 index 00000000..b9c52ff7 --- /dev/null +++ b/src/client/components/brand-kit-panel.tsx @@ -0,0 +1,333 @@ +import { useRef, useState } from "preact/hooks"; +import { Upload, Plus, Trash2, Download, Wand2 } from "lucide-preact"; +import { useEditor } from "../context"; +import { FONT_FAMILIES } from "../fonts"; +import { exportKit, importKit } from "../lib/kit-transfer"; + +export function BrandKitPanel() { + const { + brandKits, + activeBrandKit, + activeBrandKitId, + setActiveBrandKitId, + createBrandKit, + updateBrandKit, + deleteBrandKit, + applyBrandKit, + addImage, + selectedObject, + updateSelectedObject, + setBackground, + } = useEditor(); + + // A swatch click means "make the thing I have selected this colour"; with + // nothing selected the only sensible target is the page background. + const applyBrandColor = (color: string) => { + if (selectedObject) updateSelectedObject({ fill: color }); + else setBackground("color", color); + }; + + const logoInputRef = useRef(null); + const importInputRef = useRef(null); + // Holds the label of the async operation in flight, or null. One state rather + // than a boolean so the indicator says which of upload / export / import is running. + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + const handleLogoUpload = async (files: FileList | null) => { + if (!files?.length || !activeBrandKit) return; + setBusy("Uploading…"); + try { + const form = new FormData(); + form.append("file", files[0]); + const resp = await fetch("/api/uploads", { method: "POST", body: form }); + const data = await resp.json(); + if (data.url) { + await updateBrandKit(activeBrandKit.id, { logos: [...activeBrandKit.logos, data.url] }); + } + } catch (e) { + console.error("Logo upload failed:", e); + } finally { + setBusy(null); + } + }; + + const handleExport = async () => { + if (!activeBrandKit) return; + setError(null); + setBusy("Exporting…"); + try { + const { json, missing } = await exportKit(activeBrandKit); + const blob = new Blob([json], { type: "application/json" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `${activeBrandKit.name.replace(/[^\w-]+/g, "-").toLowerCase()}.brandkit.json`; + link.click(); + URL.revokeObjectURL(link.href); + if (missing > 0) setError(`Exported, but ${missing} logo(s) could not be read.`); + } finally { + setBusy(null); + } + }; + + const handleImport = async (files: FileList | null) => { + if (!files?.length) return; + setError(null); + setBusy("Importing…"); + try { + const result = await importKit(await files[0].text()); + if (!result) { + setError("That file is not an OpenDesign brand kit."); + return; + } + await createBrandKit(result.kit); + if (result.missing > 0) setError(`Imported, but ${result.missing} logo(s) could not be saved.`); + } finally { + setBusy(null); + } + }; + + return ( +
+ {/* Kit selector */} +
+ + +
+ + {!activeBrandKit ? ( +

+ A brand kit holds the colors, fonts and logos you reuse on every design. Create one, then + apply it to any design in a click. +

+ ) : ( + <> + {/* Name */} +
+ + + updateBrandKit(activeBrandKit.id, { + name: (e.target as HTMLInputElement).value || "My Brand", + }) + } + /> +
+ + {/* Colors */} +
+ +
+ {activeBrandKit.colors.map((color, i) => ( +
+ +
+ ))} + {activeBrandKit.colors.length < 24 && ( + + )} +
+
+ + {/* Fonts */} +
+
+ + +
+
+ + +
+
+ + {/* Logos */} +
+ +
+ {activeBrandKit.logos.map((url, i) => ( +
+ + +
+ ))} + {activeBrandKit.logos.length < 12 && ( + + )} +
+ handleLogoUpload((e.target as HTMLInputElement).files)} + /> +
+ + {/* Apply */} + + + {/* Portability */} +
+

+ Your kit is a plain JSON file. Take it to another OpenDesign install, or hand it to a + client. +

+
+ + + +
+ handleImport((e.target as HTMLInputElement).files)} + /> + {busy &&

{busy}

} + {error &&

{error}

} +
+ + )} +
+ ); +} diff --git a/src/client/components/left-sidebar.tsx b/src/client/components/left-sidebar.tsx index 31232941..3861eb45 100644 --- a/src/client/components/left-sidebar.tsx +++ b/src/client/components/left-sidebar.tsx @@ -10,15 +10,18 @@ import { Palette, LayoutGrid, Sparkles, + SwatchBook, } from "lucide-preact"; import { useEditor } from "../context"; import { TemplateCard } from "./template-card"; import { DesignList } from "./design-list"; +import { BrandKitPanel } from "./brand-kit-panel"; -type Section = "templates" | "text" | "shapes" | "images" | "background" | "designs"; +type Section = "templates" | "brand" | "text" | "shapes" | "images" | "background" | "designs"; const SECTIONS: { key: Section; icon: typeof LayoutGrid; label: string }[] = [ { key: "templates", icon: Sparkles, label: "Templates" }, + { key: "brand", icon: SwatchBook, label: "Brand" }, { key: "shapes", icon: Square, label: "Elements" }, { key: "text", icon: Type, label: "Text" }, { key: "images", icon: Upload, label: "Uploads" }, @@ -28,6 +31,7 @@ const SECTIONS: { key: Section; icon: typeof LayoutGrid; label: string }[] = [ const SECTION_TITLES: Record = { templates: "Templates", + brand: "Brand Kit", shapes: "Elements", text: "Text", images: "Uploads", @@ -154,6 +158,8 @@ export function LeftSidebar() { )} + {activeSection === "brand" && } + {activeSection === "text" && (

Click to add text

diff --git a/src/client/components/right-sidebar.tsx b/src/client/components/right-sidebar.tsx index 3a52b0ac..1bc2ac55 100644 --- a/src/client/components/right-sidebar.tsx +++ b/src/client/components/right-sidebar.tsx @@ -13,19 +13,31 @@ import { } from "lucide-preact"; import * as fabric from "fabric"; import { useEditor } from "../context"; +import { FONT_FAMILIES } from "../fonts"; -const FONT_FAMILIES = [ - "Inter", - "Playfair Display", - "Montserrat", - "Poppins", - "Roboto", - "Open Sans", - "Lora", - "Raleway", - "Source Sans Pro", - "Merriweather", -]; +/** + * The active kit's colours, offered wherever a colour is chosen. This is what + * "on brand" means day to day: the brand palette is one click away at the + * moment of picking, not a page you have to remember to visit. + */ +function BrandSwatches({ onPick }: { onPick: (color: string) => void }) { + const { activeBrandKit } = useEditor(); + if (!activeBrandKit || activeBrandKit.colors.length === 0) return null; + return ( +
+ {activeBrandKit.colors.map((color, i) => ( +
+ ); +} export function RightSidebar() { const { selectedObject, updateSelectedObject, deleteSelected, canvas, setBackground, canvasWidth, canvasHeight } = @@ -52,6 +64,7 @@ export function RightSidebar() { class="w-full h-8 rounded-md border border-zinc-300 cursor-pointer bg-transparent" onChange={(e) => setBackground("color", (e.target as HTMLInputElement).value)} /> + setBackground("color", color)} />
); @@ -220,6 +233,7 @@ export function RightSidebar() { } /> + updateSelectedObject({ fill: color })} /> {/* Line height */} @@ -290,6 +304,7 @@ export function RightSidebar() { } /> + updateSelectedObject({ fill: color })} /> {/* Stroke */} diff --git a/src/client/context.tsx b/src/client/context.tsx index e6abf124..845867ea 100644 --- a/src/client/context.tsx +++ b/src/client/context.tsx @@ -1,6 +1,6 @@ import { createContext } from "preact"; import { useContext } from "preact/hooks"; -import type { Design, Template, Page } from "./types"; +import type { Design, Template, Page, BrandKit } from "./types"; import type * as fabric from "fabric"; export interface CanvasSize { @@ -75,6 +75,16 @@ export interface EditorContextValue { renamePage: (pageId: string, title: string) => Promise; switchToPage: (pageId: string) => void; + // Brand kits + brandKits: BrandKit[]; + activeBrandKit: BrandKit | null; + activeBrandKitId: string | null; + setActiveBrandKitId: (id: string | null) => void; + createBrandKit: (input?: Partial>) => Promise; + updateBrandKit: (id: string, input: Partial>) => Promise; + deleteBrandKit: (id: string) => Promise; + applyBrandKit: (kit: BrandKit) => void; + // Templates templates: Template[]; diff --git a/src/client/fonts.ts b/src/client/fonts.ts new file mode 100644 index 00000000..b04e7ef6 --- /dev/null +++ b/src/client/fonts.ts @@ -0,0 +1,13 @@ +/** The fonts loaded in index.html and app.tsx, and the only ones offered anywhere. */ +export const FONT_FAMILIES = [ + "Inter", + "Playfair Display", + "Montserrat", + "Poppins", + "Roboto", + "Open Sans", + "Lora", + "Raleway", + "Source Sans Pro", + "Merriweather", +]; diff --git a/src/client/hooks/use-brand-kits.ts b/src/client/hooks/use-brand-kits.ts new file mode 100644 index 00000000..4852d4ef --- /dev/null +++ b/src/client/hooks/use-brand-kits.ts @@ -0,0 +1,68 @@ +import { useState, useCallback, useEffect } from "preact/hooks"; +import type { BrandKit } from "../types"; +import { api } from "../api"; + +type KitInput = Partial>; + +/** + * Brand kits are an install-wide resource, not a per-design one: the whole + * point is that the same colours and fonts follow every design. An agency + * running one OpenDesign for several clients keeps one kit per client, so + * this is a list with a selection rather than a single settings row. + */ +export function useBrandKits() { + const [brandKits, setBrandKits] = useState([]); + const [activeBrandKitId, setActiveBrandKitId] = useState(null); + + useEffect(() => { + (async () => { + try { + const kits = await api("GET", "/api/brand-kits"); + setBrandKits(kits); + setActiveBrandKitId((prev) => prev ?? kits[0]?.id ?? null); + } catch (e) { + console.error("Failed to load brand kits:", e); + } + })(); + }, []); + + const createBrandKit = useCallback(async (input?: KitInput) => { + const kit = await api("POST", "/api/brand-kits", input ?? {}); + setBrandKits((prev) => [...prev, kit]); + setActiveBrandKitId(kit.id); + return kit; + }, []); + + const updateBrandKit = useCallback(async (id: string, input: KitInput) => { + // Optimistic: the panel is a live editor, and waiting on a round trip for + // every swatch click makes it feel broken. + setBrandKits((prev) => prev.map((k) => (k.id === id ? { ...k, ...input } : k))); + try { + const kit = await api("PUT", `/api/brand-kits/${id}`, input); + setBrandKits((prev) => prev.map((k) => (k.id === id ? kit : k))); + } catch (e) { + console.error("Failed to save brand kit:", e); + } + }, []); + + const deleteBrandKit = useCallback(async (id: string) => { + await api<{ ok: boolean }>("DELETE", `/api/brand-kits/${id}`); + setBrandKits((prev) => { + const next = prev.filter((k) => k.id !== id); + setActiveBrandKitId((cur) => (cur === id ? next[0]?.id ?? null : cur)); + return next; + }); + }, []); + + const activeBrandKit = brandKits.find((k) => k.id === activeBrandKitId) ?? null; + + return { + brandKits, + activeBrandKit, + activeBrandKitId, + setActiveBrandKitId, + createBrandKit, + updateBrandKit, + deleteBrandKit, + }; +} diff --git a/src/client/hooks/use-canvas.ts b/src/client/hooks/use-canvas.ts index 3c2a4bdc..8f168a80 100644 --- a/src/client/hooks/use-canvas.ts +++ b/src/client/hooks/use-canvas.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useRef, useEffect } from "preact/hooks"; import * as fabric from "fabric"; -import type { Template } from "../types"; +import type { Template, BrandKit } from "../types"; +import { buildColorMap, brandFontFor, isHex } from "../lib/brand"; const MAX_HISTORY = 50; @@ -441,6 +442,49 @@ export function useCanvasState() { [getActiveCanvas, updateUndoRedoState] ); + // ── Brand kit ─────────────────────────────────────────────────────── + + // Re-brands the page in one pass: text takes the kit's heading/body font, and + // every colour in the design is swapped for the brand colour of the same + // light-to-dark rank (see lib/brand.ts). It is one history entry, so a user + // who does not like the result presses Cmd+Z once. + const applyBrandKit = useCallback( + (kit: BrandKit) => { + const canvas = getActiveCanvas(); + const pageId = activeCanvasIdRef.current; + if (!canvas || !pageId) return; + + const objects = canvas.getObjects(); + const used: string[] = []; + if (isHex(canvas.backgroundColor)) used.push(canvas.backgroundColor); + for (const obj of objects) { + if (isHex(obj.fill)) used.push(obj.fill); + if (isHex(obj.stroke)) used.push(obj.stroke); + } + const colorMap = buildColorMap(used, kit.colors); + const remap = (v: unknown) => (isHex(v) ? colorMap.get(v.toLowerCase()) ?? v : v); + + if (isHex(canvas.backgroundColor)) { + canvas.backgroundColor = remap(canvas.backgroundColor) as string; + } + + for (const obj of objects) { + const props: Record = {}; + if (isHex(obj.fill)) props.fill = remap(obj.fill); + if (isHex(obj.stroke)) props.stroke = remap(obj.stroke); + if (obj instanceof fabric.Textbox || obj instanceof fabric.IText) { + props.fontFamily = brandFontFor(obj.fontSize ?? 18, kit); + } + if (Object.keys(props).length > 0) obj.set(props as Partial); + } + + canvas.requestRenderAll(); + saveHistory(pageId); + setSelectedObject((prev) => (prev ? ({ ...prev } as fabric.FabricObject) : null)); + }, + [getActiveCanvas, saveHistory] + ); + // ── Keyboard shortcuts ────────────────────────────────────────────── useEffect(() => { @@ -504,5 +548,6 @@ export function useCanvasState() { getCanvasJSON, getCanvasJSONForPage, loadTemplate, + applyBrandKit, }; } diff --git a/src/client/lib/brand.ts b/src/client/lib/brand.ts new file mode 100644 index 00000000..762bd557 --- /dev/null +++ b/src/client/lib/brand.ts @@ -0,0 +1,101 @@ +import type { BrandKit } from "../types"; + +/** + * Recoloring a finished design to a brand palette by "nearest colour" is the + * obvious approach and the wrong one: a dark background and its white heading + * can both land on the same swatch, and the design becomes unreadable. + * + * So we map by *rank in luminance* instead. Sort the design's distinct colours + * darkest-to-lightest, sort the brand's the same way, and map position to + * position. The darkest thing in the design stays the darkest thing in the + * design; contrast survives even when the palettes look nothing alike. + */ + +const HEX = /^#[0-9a-fA-F]{6}$/; + +export function isHex(v: unknown): v is string { + return typeof v === "string" && HEX.test(v); +} + +function rgb(hex: string): [number, number, number] { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** Relative luminance, WCAG definition. */ +export function luminance(hex: string): number { + const [r, g, b] = rgb(hex).map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** + * Build a lookup from every colour used in a design to a brand colour, + * preserving light/dark order. Returns an empty map when there is nothing + * sensible to map (no brand colours, or no colours in the design). + */ +export function buildColorMap(designColors: string[], brandColors: string[]): Map { + const map = new Map(); + const brand = [...new Set(brandColors.filter(isHex))].sort((a, b) => luminance(a) - luminance(b)); + const source = [...new Set(designColors.filter(isHex))].sort((a, b) => luminance(a) - luminance(b)); + if (brand.length === 0 || source.length === 0) return map; + + source.forEach((color, i) => { + // Single-colour source maps to the mid brand colour rather than the darkest. + const ratio = source.length === 1 ? 0.5 : i / (source.length - 1); + map.set(color.toLowerCase(), brand[Math.round(ratio * (brand.length - 1))]); + }); + return map; +} + +/** + * Which brand font a text object should take. Mirrors the editor's own presets + * (heading 48 / subheading 32 / body 18) so "Add a heading" and a heading in a + * template are treated the same way. + */ +export function brandFontFor(fontSize: number, kit: BrandKit): string { + return fontSize >= 32 ? kit.heading_font : kit.body_font; +} + +/** The kit as it is written to disk by "Export kit". */ +export function serializeKit(kit: BrandKit) { + return { + opendesign_brand_kit: 1, + name: kit.name, + colors: kit.colors, + heading_font: kit.heading_font, + body_font: kit.body_font, + logos: kit.logos, + }; +} + +/** + * Read a kit file back. Returns null rather than throwing so the caller can + * show one honest "that is not a brand kit file" message. + */ +export function parseKitFile(raw: string): Omit | null { + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + return null; + } + if (!data || typeof data !== "object") return null; + const d = data as Record; + if (typeof d.name !== "string" || !d.name.trim()) return null; + + const colors = Array.isArray(d.colors) ? d.colors.filter(isHex).slice(0, 24) : []; + const logos = Array.isArray(d.logos) + ? d.logos.filter((l): l is string => typeof l === "string").slice(0, 12) + : []; + + return { + name: d.name.slice(0, 80), + colors, + logos, + heading_font: typeof d.heading_font === "string" && d.heading_font ? d.heading_font : "Montserrat", + body_font: typeof d.body_font === "string" && d.body_font ? d.body_font : "Inter", + }; +} diff --git a/src/client/lib/kit-transfer.ts b/src/client/lib/kit-transfer.ts new file mode 100644 index 00000000..499cead6 --- /dev/null +++ b/src/client/lib/kit-transfer.ts @@ -0,0 +1,88 @@ +import type { BrandKit } from "../types"; +import { serializeKit, parseKitFile } from "./brand"; + +/** + * Getting a kit out of one install and into another. + * + * Logos are stored as install-relative paths (`/api/uploads/x.png`), which mean + * nothing anywhere else — export them as-is and the colours and fonts travel + * while every logo 404s on arrival. So a kit *leaves* with its logos inlined as + * data URIs, carrying nothing that points back here, and *arrives* by moving + * those bytes into the receiving install's own storage. + * + * Storing the data URI directly would be less code, but it would push the whole + * logo into every design's canvas JSON. Rehydrating keeps stored values short. + */ + +async function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); +} + +async function inlineLogo(url: string): Promise { + try { + const resp = await fetch(url); + if (!resp.ok) return null; + return await blobToDataUrl(await resp.blob()); + } catch { + return null; + } +} + +async function storeLogo(dataUrl: string): Promise { + try { + const blob = await (await fetch(dataUrl)).blob(); + // The upload route keys the stored file off its extension, so derive one + // from the MIME type rather than the (absent) original filename. + const ext = (blob.type.split("/")[1] || "png").replace("svg+xml", "svg"); + const form = new FormData(); + form.append("file", new File([blob], `logo.${ext}`, { type: blob.type })); + const resp = await fetch("/api/uploads", { method: "POST", body: form }); + const data = await resp.json(); + return typeof data.url === "string" ? data.url : null; + } catch { + return null; + } +} + +/** + * A kit as a portable file. `missing` counts logos that could not be read, so + * the caller can say so instead of silently shipping an incomplete kit. + */ +export async function exportKit(kit: BrandKit): Promise<{ json: string; missing: number }> { + const inlined = await Promise.all(kit.logos.map(inlineLogo)); + const logos = inlined.filter((l): l is string => l !== null); + return { + json: JSON.stringify({ ...serializeKit(kit), logos }, null, 2), + missing: kit.logos.length - logos.length, + }; +} + +/** + * Read a kit file, moving any inlined logo into this install's storage. Returns + * null when the file is not a kit at all. + */ +export async function importKit( + raw: string +): Promise<{ kit: Omit; missing: number } | null> { + const parsed = parseKitFile(raw); + if (!parsed) return null; + + const logos: string[] = []; + let missing = 0; + for (const logo of parsed.logos) { + // A path means the file came from this install, so it already resolves. + if (!logo.startsWith("data:")) { + logos.push(logo); + continue; + } + const stored = await storeLogo(logo); + if (stored) logos.push(stored); + else missing++; + } + return { kit: { ...parsed, logos }, missing }; +} diff --git a/src/client/types.ts b/src/client/types.ts index 7df0db61..a1c269e8 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -22,6 +22,17 @@ export interface DesignWithPages extends Design { pages: Page[]; } +export interface BrandKit { + id: string; + name: string; + colors: string[]; + heading_font: string; + body_font: string; + logos: string[]; + created_at: string; + updated_at: string; +} + export interface Template { id: string; name: string; diff --git a/src/server/index.ts b/src/server/index.ts index 030b34af..36cf2e2b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -345,6 +345,134 @@ app.openapi(getTemplate, async (c) => { return c.json(row, 200); }); +// ── Brand kits ────────────────────────────────────────────────────── + +// A brand kit is the colors / fonts / logos a team reuses across every design. +// It is deliberately a plain, self-describing shape: the JSON the API returns is +// exactly what "Export kit" writes to disk and what "Import kit" POSTs back, so a +// kit can move between OpenDesign installs without anyone rebuilding it by hand. + +const HEX = /^#[0-9a-fA-F]{6}$/; + +const BrandKitBodySchema = z.object({ + name: z.string().min(1).max(80), + colors: z.array(z.string().regex(HEX)).max(24), + heading_font: z.string().min(1).max(60), + body_font: z.string().min(1).max(60), + logos: z.array(z.string().max(2048)).max(12), +}); + +const BrandKitSchema = BrandKitBodySchema.extend({ + id: z.string(), + created_at: z.string(), + updated_at: z.string(), +}); + +// Rows keep the list columns as TEXT; the API speaks real arrays. +interface BrandKitRow { + id: string; + name: string; + colors: string; + heading_font: string; + body_font: string; + logos: string; + created_at: string; + updated_at: string; +} + +function parseList(raw: string): string[] { + try { + const v = JSON.parse(raw); + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; + } catch { + return []; + } +} + +function toBrandKit(row: BrandKitRow): z.infer { + return { ...row, colors: parseList(row.colors), logos: parseList(row.logos) }; +} + +const listBrandKits = createRoute({ + method: "get", + path: "/api/brand-kits", + responses: { 200: { content: { "application/json": { schema: z.array(BrandKitSchema) } }, description: "OK" } }, +}); + +app.openapi(listBrandKits, async (c) => { + const rows = await query("SELECT * FROM brand_kits ORDER BY created_at"); + return c.json(rows.map(toBrandKit), 200); +}); + +const createBrandKit = createRoute({ + method: "post", + path: "/api/brand-kits", + request: { body: { content: { "application/json": { schema: BrandKitBodySchema.partial() } } } }, + responses: { 200: { content: { "application/json": { schema: BrandKitSchema } }, description: "OK" } }, +}); + +app.openapi(createBrandKit, async (c) => { + const b = c.req.valid("json"); + await run( + "INSERT INTO brand_kits (name, colors, heading_font, body_font, logos) VALUES (?, ?, ?, ?, ?)", + [ + b.name ?? "My Brand", + JSON.stringify(b.colors ?? []), + b.heading_font ?? "Montserrat", + b.body_font ?? "Inter", + JSON.stringify(b.logos ?? []), + ] + ); + const row = await get("SELECT * FROM brand_kits ORDER BY created_at DESC, rowid DESC LIMIT 1"); + return c.json(toBrandKit(row!), 200); +}); + +const updateBrandKit = createRoute({ + method: "put", + path: "/api/brand-kits/{id}", + request: { + params: z.object({ id: z.string() }), + body: { content: { "application/json": { schema: BrandKitBodySchema.partial() } } }, + }, + responses: { + 200: { content: { "application/json": { schema: BrandKitSchema } }, description: "OK" }, + 404: { content: { "application/json": { schema: ErrorSchema } }, description: "Not found" }, + }, +}); + +app.openapi(updateBrandKit, async (c) => { + const { id } = c.req.valid("param"); + const b = c.req.valid("json"); + const existing = await get("SELECT * FROM brand_kits WHERE id = ?", [id]); + if (!existing) return c.json({ error: "Not found" }, 404); + await run( + `UPDATE brand_kits SET name = ?, colors = ?, heading_font = ?, body_font = ?, logos = ?, updated_at = datetime('now') WHERE id = ?`, + [ + b.name ?? existing.name, + b.colors ? JSON.stringify(b.colors) : existing.colors, + b.heading_font ?? existing.heading_font, + b.body_font ?? existing.body_font, + b.logos ? JSON.stringify(b.logos) : existing.logos, + id, + ] + ); + const row = await get("SELECT * FROM brand_kits WHERE id = ?", [id]); + return c.json(toBrandKit(row!), 200); +}); + +const deleteBrandKit = createRoute({ + method: "delete", + path: "/api/brand-kits/{id}", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: { content: { "application/json": { schema: z.object({ ok: z.boolean() }) } }, description: "OK" } }, +}); + +app.openapi(deleteBrandKit, async (c) => { + const { id } = c.req.valid("param"); + await run("DELETE FROM brand_kits WHERE id = ?", [id]); + return c.json({ ok: true }, 200); +}); + // ── File uploads ──────────────────────────────────────────────────── app.post("/api/uploads", async (c) => { diff --git a/src/server/schema.sql b/src/server/schema.sql index 42c77caf..912f7b4a 100644 --- a/src/server/schema.sql +++ b/src/server/schema.sql @@ -42,3 +42,16 @@ INSERT OR IGNORE INTO templates (id, name, category, canvas_json, width, height, ('tips-list', 'Tips List', 'linkedin', '{"version":"6.0.0","objects":[{"type":"rect","left":0,"top":0,"width":1080,"height":1080,"fill":"#fafaf9"},{"type":"textbox","left":80,"top":80,"width":920,"text":"5 Tips for Better\nProductivity","fontSize":44,"fontFamily":"Montserrat","fontWeight":"800","fill":"#1c1917"},{"type":"textbox","left":80,"top":280,"width":920,"text":"1. Start with the hardest task\n\n2. Time-block your calendar\n\n3. Limit notifications\n\n4. Take regular breaks\n\n5. Review and reflect daily","fontSize":28,"fontFamily":"Inter","fontWeight":"400","fill":"#44403c","lineHeight":1.6}]}', 1080, 1080, 4), ('profile-card', 'Profile Card', 'linkedin', '{"version":"6.0.0","objects":[{"type":"rect","left":0,"top":0,"width":1080,"height":1080,"fill":"#18181b"},{"type":"circle","left":440,"top":180,"radius":100,"fill":"#3f3f46"},{"type":"textbox","left":80,"top":420,"width":920,"text":"Jane Smith","fontSize":40,"fontFamily":"Montserrat","fontWeight":"700","fill":"#fafafa","textAlign":"center"},{"type":"textbox","left":80,"top":490,"width":920,"text":"Product Designer @ TechCo","fontSize":22,"fontFamily":"Inter","fontWeight":"400","fill":"#a1a1aa","textAlign":"center"},{"type":"textbox","left":140,"top":600,"width":800,"text":"Passionate about creating intuitive user experiences that make complex tools feel simple.","fontSize":20,"fontFamily":"Inter","fontWeight":"400","fill":"#d4d4d8","textAlign":"center"}]}', 1080, 1080, 5), ('minimal-text', 'Minimal Text', 'linkedin', '{"version":"6.0.0","objects":[{"type":"rect","left":0,"top":0,"width":1080,"height":1080,"fill":"#f8fafc"},{"type":"textbox","left":120,"top":380,"width":840,"text":"Less is more.","fontSize":64,"fontFamily":"Playfair Display","fontWeight":"600","fill":"#0f172a","textAlign":"center"},{"type":"textbox","left":120,"top":520,"width":840,"text":"Sometimes the simplest message\nhas the biggest impact.","fontSize":22,"fontFamily":"Inter","fontWeight":"400","fill":"#64748b","textAlign":"center"}]}', 1080, 1080, 6); + +CREATE TABLE IF NOT EXISTS brand_kits ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))), + name TEXT NOT NULL DEFAULT 'My Brand', + -- JSON arrays/objects, kept as TEXT so the whole kit round-trips through + -- export/import as one portable blob. + colors TEXT NOT NULL DEFAULT '[]', + heading_font TEXT NOT NULL DEFAULT 'Montserrat', + body_font TEXT NOT NULL DEFAULT 'Inter', + logos TEXT NOT NULL DEFAULT '[]', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +);