Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ data.db-wal
data.db-shm
uploads/
.env
.wrangler/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
3 changes: 3 additions & 0 deletions src/client/app.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand Down
333 changes: 333 additions & 0 deletions src/client/components/brand-kit-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const importInputRef = useRef<HTMLInputElement>(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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div class="flex flex-col gap-4">
{/* Kit selector */}
<div class="flex gap-1.5">
<select
class="flex-1 min-w-0 bg-white border border-zinc-300 rounded-md text-xs text-zinc-700 px-2 py-1.5 outline-none cursor-pointer focus:border-accent"
value={activeBrandKitId ?? ""}
onChange={(e) => setActiveBrandKitId((e.target as HTMLSelectElement).value || null)}
aria-label="Active brand kit"
>
{brandKits.length === 0 && <option value="">No brand kit yet</option>}
{brandKits.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
<button
class="shrink-0 p-1.5 rounded-md border border-zinc-300 bg-white text-zinc-500 cursor-pointer hover:border-accent hover:text-accent transition-all"
onClick={() => createBrandKit()}
title="New brand kit"
aria-label="New brand kit"
>
<Plus size={14} />
</button>
</div>

{!activeBrandKit ? (
<p class="text-[11px] text-zinc-400 leading-relaxed">
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.
</p>
) : (
<>
{/* Name */}
<div>
<label class="text-[11px] text-zinc-400 mb-1 block">Kit name</label>
<input
type="text"
class="w-full bg-white border border-zinc-300 rounded-md text-xs text-zinc-700 px-2 py-1.5 outline-none focus:border-accent"
value={activeBrandKit.name}
onChange={(e) =>
updateBrandKit(activeBrandKit.id, {
name: (e.target as HTMLInputElement).value || "My Brand",
})
}
/>
</div>

{/* Colors */}
<div>
<label class="text-[11px] text-zinc-400 mb-1.5 block">
Brand colors{" "}
<span class="text-zinc-300">— click to apply to selection</span>
</label>
<div class="grid grid-cols-5 gap-1.5">
{activeBrandKit.colors.map((color, i) => (
<div key={`${color}-${i}`} class="relative group">
<button
class="w-full aspect-square rounded-md border border-zinc-300 cursor-pointer transition-all hover:scale-110 hover:border-accent"
style={{ background: color }}
title={color}
aria-label={`Brand color ${color}`}
onClick={() => applyBrandColor(color)}
/>
<button
class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-zinc-700 text-white border-none cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title={`Remove ${color}`}
aria-label={`Remove brand color ${color}`}
onClick={() =>
updateBrandKit(activeBrandKit.id, {
colors: activeBrandKit.colors.filter((_, j) => j !== i),
})
}
>
<span class="text-[9px] leading-none">×</span>
</button>
</div>
))}
{activeBrandKit.colors.length < 24 && (
<label class="w-full aspect-square rounded-md border border-dashed border-zinc-300 cursor-pointer flex items-center justify-center text-zinc-400 hover:border-accent hover:text-accent transition-all">
<Plus size={14} />
<input
type="color"
class="sr-only"
aria-label="Add brand color"
onChange={(e) =>
updateBrandKit(activeBrandKit.id, {
colors: [...activeBrandKit.colors, (e.target as HTMLInputElement).value],
})
}
/>
</label>
)}
</div>
</div>

{/* Fonts */}
<div class="flex flex-col gap-2">
<div>
<label class="text-[11px] text-zinc-400 mb-1 block">Heading font</label>
<select
class="w-full bg-white border border-zinc-300 rounded-md text-xs text-zinc-700 px-2 py-1.5 outline-none cursor-pointer focus:border-accent"
value={activeBrandKit.heading_font}
onChange={(e) =>
updateBrandKit(activeBrandKit.id, {
heading_font: (e.target as HTMLSelectElement).value,
})
}
>
{FONT_FAMILIES.map((f) => (
<option key={f} value={f} style={{ fontFamily: f }}>
{f}
</option>
))}
</select>
</div>
<div>
<label class="text-[11px] text-zinc-400 mb-1 block">Body font</label>
<select
class="w-full bg-white border border-zinc-300 rounded-md text-xs text-zinc-700 px-2 py-1.5 outline-none cursor-pointer focus:border-accent"
value={activeBrandKit.body_font}
onChange={(e) =>
updateBrandKit(activeBrandKit.id, {
body_font: (e.target as HTMLSelectElement).value,
})
}
>
{FONT_FAMILIES.map((f) => (
<option key={f} value={f} style={{ fontFamily: f }}>
{f}
</option>
))}
</select>
</div>
</div>

{/* Logos */}
<div>
<label class="text-[11px] text-zinc-400 mb-1.5 block">
Logos <span class="text-zinc-300">— click to place</span>
</label>
<div class="grid grid-cols-3 gap-1.5">
{activeBrandKit.logos.map((url, i) => (
<div key={url} class="relative group">
<button
class="w-full aspect-square rounded-md border border-zinc-200 bg-[repeating-conic-gradient(#f4f4f5_0%_25%,#ffffff_0%_50%)] bg-[length:12px_12px] cursor-pointer p-1 hover:border-accent transition-all"
onClick={() => addImage(url)}
title="Add logo to canvas"
>
<img src={url} alt="" class="w-full h-full object-contain" />
</button>
<button
class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-zinc-700 text-white border-none cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
aria-label="Remove logo"
onClick={() =>
updateBrandKit(activeBrandKit.id, {
logos: activeBrandKit.logos.filter((_, j) => j !== i),
})
}
>
<span class="text-[9px] leading-none">×</span>
</button>
</div>
))}
{activeBrandKit.logos.length < 12 && (
<button
class="w-full aspect-square rounded-md border border-dashed border-zinc-300 cursor-pointer flex items-center justify-center text-zinc-400 hover:border-accent hover:text-accent transition-all"
onClick={() => logoInputRef.current?.click()}
aria-label="Upload logo"
>
<Upload size={14} />
</button>
)}
</div>
<input
ref={logoInputRef}
type="file"
accept="image/*"
class="hidden"
onChange={(e) => handleLogoUpload((e.target as HTMLInputElement).files)}
/>
</div>

{/* Apply */}
<button
class="w-full flex items-center justify-center gap-1.5 py-2 rounded-lg bg-accent text-white border-none cursor-pointer text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => applyBrandKit(activeBrandKit)}
disabled={activeBrandKit.colors.length === 0}
title={
activeBrandKit.colors.length === 0
? "Add at least one brand color first"
: "Apply brand fonts and colors to this page"
}
>
<Wand2 size={13} />
Apply to this page
</button>

{/* Portability */}
<div class="pt-1 border-t border-zinc-200">
<p class="text-[10px] text-zinc-400 mt-2 mb-1.5 leading-relaxed">
Your kit is a plain JSON file. Take it to another OpenDesign install, or hand it to a
client.
</p>
<div class="flex gap-1.5">
<button
class="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-md border border-zinc-300 bg-white text-[11px] text-zinc-500 cursor-pointer hover:border-accent hover:text-accent transition-all"
onClick={handleExport}
>
<Download size={12} />
Export
</button>
<button
class="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-md border border-zinc-300 bg-white text-[11px] text-zinc-500 cursor-pointer hover:border-accent hover:text-accent transition-all"
onClick={() => importInputRef.current?.click()}
>
<Upload size={12} />
Import
</button>
<button
class="shrink-0 p-1.5 rounded-md border border-zinc-300 bg-white text-zinc-400 cursor-pointer hover:border-red-400 hover:text-red-500 transition-all"
onClick={() => deleteBrandKit(activeBrandKit.id)}
title="Delete this kit"
aria-label="Delete this brand kit"
>
<Trash2 size={12} />
</button>
</div>
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
class="hidden"
onChange={(e) => handleImport((e.target as HTMLInputElement).files)}
/>
{busy && <p class="text-[10px] text-zinc-400 mt-1.5">{busy}</p>}
{error && <p class="text-[10px] text-red-500 mt-1.5">{error}</p>}
</div>
</>
)}
</div>
);
}
Loading