feat(og-generator): custom color logos for partnerships/acquisitions

Line icons are stroke-only by brand rule, but a partner's logo (Grafana,
Multigres, etc.) needs to render in ITS OWN colors — a second asset kind
alongside the existing icon system, not a replacement for it.

- supabase/migrations/0002_logo_assets.sql: add width/height to `assets`
  (kind + storage_path already existed from 0001)
- lib/assets/sanitize-svg.ts: sanitizeLogoSvg() — a separate allowlist that
  PRESERVES fill/stroke/gradients/style color declarations (unlike the icon
  sanitizer, which strips them for the stroke-only treatment), while still
  blocking scripts, event handlers, and external references (internal `#id`
  refs are kept so gradient defs/<use> keep working). Unit-tested against a
  mixed color+attack payload.
- lib/supabase/assets.ts: insertLogoAsset() uploads to the og-assets Storage
  bucket (raster-safe, unlike inline SVG bodies) and records client-measured
  width/height; rowToIcon exposes kind/url/width/height for both kinds.
- app/api/assets/route.ts: POST branches on a `kind` field ('icon', default,
  or 'logo'); logo accepts SVG/PNG/JPEG/WebP up to 2MB, requires width+height.
- app/api/og/route.tsx: icon slots render logos as-is (no stroke
  normalization), fit to their natural aspect ratio via a new fitBox() helper.
- app/page.tsx: a second "+ Upload logo (color)" control measures the file's
  natural dimensions in-browser (Image().onload) before POSTing; the icon
  picker renders logo entries as real <img> (actual colors) instead of the
  forced-stroke SVG redraw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ShaneandClaude Opus 4.8 committed 2026-07-01 16:03:02 -05:00
1 parent a15601fe9c
commit 5ec0a89f5d
7 files changed
+482 -81

No files matched your search

+115 -28
View File
@@ -1,5 +1,5 @@
import { sanitizeSvg } from '@/lib/assets/sanitize-svg'
import { insertAsset, listAssets } from '@/lib/supabase/assets'
import { sanitizeLogoSvg, sanitizeSvg } from '@/lib/assets/sanitize-svg'
import { insertAsset, insertLogoAsset, listAssets } from '@/lib/supabase/assets'
import { getSupabaseAdmin } from '@/lib/supabase/server'
// Node runtime — uses the Supabase server clients + parses uploaded files.
@@ -16,43 +16,64 @@ function slugify(s: string): string {
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'icon'
.slice(0, 40) || 'asset'
)
}
const NO_ADMIN_ERROR =
'Uploads need SUPABASE_SECRET_KEY in .env.local (and the migrations applied).'
const RASTER_TYPES: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
}
const MAX_LOGO_BYTES = 2_000_000 // 2 MB
/**
* POST (multipart: `file` = SVG, optional `label`) → upload a line-art icon.
* Sanitizes the SVG, then inserts it via the admin client. Writes need
* SUPABASE_SECRET_KEY; without it we return a clear, actionable error.
* POST — two upload kinds, selected by the `kind` form field ('icon', default,
* or 'logo'):
*
* - kind=icon (multipart: `file` = SVG, optional `label`): a line-art icon.
* Sanitized to stroke-only and inserted inline (unchanged behavior).
* - kind=logo (multipart: `file` = SVG/PNG/JPEG/WebP, `label`, `width`,
* `height`): a full-color partner/acquisition logo (brief follow-up).
* Colors are preserved; the file is stored in the og-assets bucket rather
* than inline, and `width`/`height` (client-measured) drive aspect-correct
* rendering later.
*
* Both need SUPABASE_SECRET_KEY — without it we return a clear 503 so the
* button never just silently fails.
*/
export async function POST(req: Request): Promise<Response> {
if (!getSupabaseAdmin()) {
return Response.json(
{
error:
'Uploads need SUPABASE_SECRET_KEY in .env.local (and the 0001_init.sql migration applied).',
},
{ status: 503 }
)
return Response.json({ error: NO_ADMIN_ERROR }, { status: 503 })
}
let svgText = ''
let label = ''
let form: FormData
try {
const form = await req.formData()
const file = form.get('file')
label = typeof form.get('label') === 'string' ? (form.get('label') as string) : ''
if (file instanceof File) {
if (file.size > 100_000) {
return Response.json({ error: 'SVG too large (max 100 KB).' }, { status: 400 })
}
svgText = await file.text()
if (!label) label = file.name.replace(/\.svg$/i, '')
}
form = await req.formData()
} catch {
return Response.json({ error: 'Expected multipart form data with an SVG file.' }, { status: 400 })
return Response.json({ error: 'Expected multipart form data.' }, { status: 400 })
}
const kind = form.get('kind') === 'logo' ? 'logo' : 'icon'
return kind === 'logo' ? handleLogoUpload(form) : handleIconUpload(form)
}
async function handleIconUpload(form: FormData): Promise<Response> {
const file = form.get('file')
let label = typeof form.get('label') === 'string' ? (form.get('label') as string) : ''
if (!(file instanceof File)) {
return Response.json({ error: 'Expected an SVG file.' }, { status: 400 })
}
if (file.size > 100_000) {
return Response.json({ error: 'SVG too large (max 100 KB).' }, { status: 400 })
}
const svgText = await file.text()
if (!label) label = file.name.replace(/\.svg$/i, '')
const clean = sanitizeSvg(svgText)
if (!clean) {
return Response.json(
@@ -69,9 +90,75 @@ export async function POST(req: Request): Promise<Response> {
const asset = await insertAsset({ name, label: displayLabel, tags, viewBox: clean.viewBox, body: clean.body })
return Response.json({ asset })
} catch (err) {
console.error('[api/assets] insert failed:', err)
console.error('[api/assets] icon insert failed:', err)
return Response.json({ error: 'Could not save the icon — is 0001_init.sql applied?' }, { status: 500 })
}
}
async function handleLogoUpload(form: FormData): Promise<Response> {
const file = form.get('file')
let label = typeof form.get('label') === 'string' ? (form.get('label') as string) : ''
const width = Number(form.get('width'))
const height = Number(form.get('height'))
if (!(file instanceof File)) {
return Response.json({ error: 'Expected an SVG, PNG, JPEG, or WebP file.' }, { status: 400 })
}
if (file.size > MAX_LOGO_BYTES) {
return Response.json({ error: 'Logo too large (max 2 MB).' }, { status: 400 })
}
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return Response.json({ error: 'Missing or invalid logo dimensions.' }, { status: 400 })
}
if (!label) label = file.name.replace(/\.\w+$/, '')
const displayLabel = (label || 'Logo').trim().slice(0, 40) || 'Logo'
const name = `${slugify(displayLabel)}-${Date.now().toString(36).slice(-4)}`
const tags = Array.from(new Set(slugify(displayLabel).split('-').filter((w) => w.length > 1)))
let fileBody: Buffer
let contentType: string
let ext: string
const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')
if (isSvg) {
const clean = sanitizeLogoSvg(await file.text())
if (!clean) {
return Response.json(
{ error: 'Not a usable SVG logo (empty, too complex, or contained unsupported content).' },
{ status: 400 }
)
}
fileBody = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${clean.viewBox}">${clean.body}</svg>`
)
contentType = 'image/svg+xml'
ext = 'svg'
} else {
ext = RASTER_TYPES[file.type]
if (!ext) {
return Response.json({ error: 'Unsupported file type — use SVG, PNG, JPEG, or WebP.' }, { status: 400 })
}
fileBody = Buffer.from(await file.arrayBuffer())
contentType = file.type
}
try {
const asset = await insertLogoAsset({
name,
label: displayLabel,
tags,
fileBody,
contentType,
ext,
width: Math.round(width),
height: Math.round(height),
})
return Response.json({ asset })
} catch (err) {
console.error('[api/assets] logo insert failed:', err)
return Response.json(
{ error: 'Could not save the asset — is the 0001_init.sql migration applied?' },
{ error: 'Could not save the logo — is 0002_logo_assets.sql applied?' },
{ status: 500 }
)
}
+32 -13
View File
@@ -34,6 +34,12 @@ const EYEBROW = typography.roles.eyebrow
const ICON_SIZE = 220 // OG icon size (1x design px)
const ICON_STROKE = illustration.defaultStrokePx
/** Scale (naturalW, naturalH) to fit within a boxSize square, preserving aspect ratio. */
function fitBox(naturalW: number, naturalH: number, boxSize: number): { w: number; h: number } {
const ratio = naturalW / naturalH || 1
return ratio >= 1 ? { w: boxSize, h: boxSize / ratio } : { w: boxSize * ratio, h: boxSize }
}
const THUMB_ICON_DEFAULT = 380
const THUMB_ICON_MIN = 160
const THUMB_ICON_MAX = 480
@@ -120,7 +126,15 @@ export async function GET(req: Request) {
}}
>
{patternLayer(cfg)}
{iconObj ? (
{iconObj && iconObj.kind === 'logo' && iconObj.url ? (
// Custom color logo — rendered as-is (no stroke normalization),
// fit to its natural aspect ratio (brief follow-up: partnerships).
// eslint-disable-next-line @next/next/no-img-element
<img
{...fitBox(iconObj.width ?? 1, iconObj.height ?? 1, thumbSize * s)}
src={iconObj.url}
/>
) : iconObj ? (
// eslint-disable-next-line @next/next/no-img-element
<img
width={thumbSize * s}
@@ -245,18 +259,23 @@ export async function GET(req: Request) {
</div>
)
const iconEl = iconObj ? (
// eslint-disable-next-line @next/next/no-img-element
<img
width={ICON_SIZE * s}
height={ICON_SIZE * s}
src={iconDataUri(iconObj, {
sizePx: ICON_SIZE * s,
strokePx: ICON_STROKE * s,
color: color('illustration.stroke'),
})}
/>
) : null
const iconEl =
iconObj && iconObj.kind === 'logo' && iconObj.url ? (
// Custom color logo — rendered as-is, fit to its natural aspect ratio.
// eslint-disable-next-line @next/next/no-img-element
<img {...fitBox(iconObj.width ?? 1, iconObj.height ?? 1, ICON_SIZE * s)} src={iconObj.url} />
) : iconObj ? (
// eslint-disable-next-line @next/next/no-img-element
<img
width={ICON_SIZE * s}
height={ICON_SIZE * s}
src={iconDataUri(iconObj, {
sizePx: ICON_SIZE * s,
strokePx: ICON_STROKE * s,
color: color('illustration.stroke'),
})}
/>
) : null
const cfg = resolvePattern(template.defaultPattern)
// Grid-snap (§4): phase the pattern so a grid line lands on the safe-area
+103 -33
View File
@@ -340,6 +340,48 @@ export default function Page() {
setUploading(false)
}
}
// Custom color logos (partnerships, acquisitions, co-marketing) — rendered
// full-color, no stroke normalization. The browser measures natural pixel
// size before upload so /api/og can fit it without distortion.
const [uploadingLogo, setUploadingLogo] = useState(false)
const [logoError, setLogoError] = useState<string | null>(null)
const uploadLogo = async (file: File) => {
setUploadingLogo(true)
setLogoError(null)
try {
const { width, height } = await new Promise<{ width: number; height: number }>((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight })
URL.revokeObjectURL(url)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Could not read the image — is it a valid SVG/PNG/JPEG/WebP?'))
}
img.src = url
})
const fd = new FormData()
fd.append('file', file)
fd.append('kind', 'logo')
fd.append('width', String(width))
fd.append('height', String(height))
const res = await fetch('/api/assets', { method: 'POST', body: fd })
const data = await res.json()
if (!res.ok) {
setLogoError(data.error ?? 'Upload failed')
return
}
setUploadedIcons((prev) => [data.asset as SeedIcon, ...prev])
setIcon((data.asset as SeedIcon).name)
} catch (err) {
setLogoError(err instanceof Error ? err.message : 'Upload failed — please try again.')
} finally {
setUploadingLogo(false)
}
}
const [thumbSize, setThumbSize] = useState(380)
const [scale, setScale] = useState<1 | 2>(1)
const [showSafeArea, setShowSafeArea] = useState(false)
@@ -677,49 +719,77 @@ export default function Page() {
key={ic.name}
type="button"
onClick={() => setIcon(ic.name)}
title={ic.label}
className={`flex h-14 items-center justify-center rounded-md border ${
title={ic.kind === 'logo' ? `${ic.label} (color logo)` : ic.label}
className={`flex h-14 items-center justify-center rounded-md border p-1.5 ${
icon === ic.name
? 'border-brand bg-brand/10 text-brand'
: 'border-default bg-surface-100 text-foreground-light hover:border-strong'
}`}
>
<svg
width={22}
height={22}
viewBox={ic.viewBox}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
dangerouslySetInnerHTML={{ __html: ic.body }}
/>
{ic.kind === 'logo' && ic.url ? (
// Real colors, no forced stroke — this is the point of a logo.
// eslint-disable-next-line @next/next/no-img-element
<img src={ic.url} alt={ic.label} className="max-h-full max-w-full object-contain" />
) : (
<svg
width={22}
height={22}
viewBox={ic.viewBox}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
dangerouslySetInnerHTML={{ __html: ic.body }}
/>
)}
</button>
))}
</div>
<label
className={`rounded-md border border-dashed border-default px-3 py-2 text-center text-xs text-foreground-light hover:border-strong ${
uploading ? 'cursor-wait opacity-70' : 'cursor-pointer'
}`}
>
{uploading ? 'Uploading…' : '+ Upload SVG icon'}
<input
type="file"
accept=".svg,image/svg+xml"
className="hidden"
disabled={uploading}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) uploadSvg(f)
e.target.value = ''
}}
/>
</label>
<div className="grid grid-cols-2 gap-2">
<label
className={`rounded-md border border-dashed border-default px-3 py-2 text-center text-xs text-foreground-light hover:border-strong ${
uploading ? 'cursor-wait opacity-70' : 'cursor-pointer'
}`}
>
{uploading ? 'Uploading…' : '+ Upload SVG icon'}
<input
type="file"
accept=".svg,image/svg+xml"
className="hidden"
disabled={uploading}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) uploadSvg(f)
e.target.value = ''
}}
/>
</label>
<label
className={`rounded-md border border-dashed border-default px-3 py-2 text-center text-xs text-foreground-light hover:border-strong ${
uploadingLogo ? 'cursor-wait opacity-70' : 'cursor-pointer'
}`}
>
{uploadingLogo ? 'Uploading…' : '+ Upload logo (color)'}
<input
type="file"
accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
className="hidden"
disabled={uploadingLogo}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) uploadLogo(f)
e.target.value = ''
}}
/>
</label>
</div>
{uploadError && <p className="text-xs text-warning-600">{uploadError}</p>}
{logoError && <p className="text-xs text-warning-600">{logoError}</p>}
<p className="text-xs text-foreground-lighter">
Line-art SVGs become shared icons (stored in Supabase, re-drawn with the locked
stroke). Uploading needs the Supabase secret key configured.
Line-art SVGs become shared icons, re-drawn with the locked stroke. Logos (SVG, PNG,
JPEG, or WebP) keep their original colors — for partnerships, acquisitions, and
co-marketing. Both are stored in Supabase and need the secret key configured.
</p>
</div>
</Group>
@@ -78,3 +78,124 @@ export function sanitizeSvg(input: string): SanitizedSvg | null {
return { viewBox, body }
}
/**
* Sanitizer for uploaded LOGOS (partnerships, acquisitions, co-marketing) —
* unlike `sanitizeSvg`, this preserves the logo's original colors (fill,
* stroke, gradients) instead of forcing the stroke-only brand treatment, since
* a partner's logo must render in its own brand colors, not ours. Still an
* allowlist: no scripts, no external references, no event handlers — the
* safety bar is the same, only the "what survives" set is different.
*/
const ALLOWED_LOGO_ELEMENTS = new Set([
'path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon', 'g',
'defs', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'symbol', 'use',
])
const DANGEROUS_LOGO_BLOCKS =
/<\s*(script|style|foreignObject|animate\w*|set|a|image|text|iframe|marker)\b[\s\S]*?<\s*\/\s*\1\s*>/gi
const DANGEROUS_LOGO_SELFCLOSE =
/<\s*(script|style|foreignObject|animate\w*|set|image|iframe)\b[^>]*\/?\s*>/gi
const SAFE_STYLE_PROPS = new Set([
'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray',
'opacity', 'fill-opacity', 'stroke-opacity', 'stop-color', 'stop-opacity', 'fill-rule', 'clip-rule',
])
/** Only allow `url(#internal)` references; reject external/data/js URLs. */
function isSafeUrlRef(value: string): boolean {
const m = value.match(/url\(\s*['"]?([^'")]*)['"]?\s*\)/i)
if (!m) return true // no url(...) present — nothing to check
return m[1].startsWith('#')
}
function sanitizeStyleValue(raw: string): string | null {
const kept = raw
.split(';')
.map((decl) => {
const i = decl.indexOf(':')
if (i === -1) return null
const prop = decl.slice(0, i).trim().toLowerCase()
const val = decl.slice(i + 1).trim()
if (!SAFE_STYLE_PROPS.has(prop)) return null
if (/javascript|expression\(|@import/i.test(val)) return null
if (!isSafeUrlRef(val)) return null
return `${prop}:${val}`
})
.filter((d): d is string => d !== null)
return kept.length ? kept.join(';') : null
}
export function sanitizeLogoSvg(input: string): SanitizedSvg | null {
if (!input || input.length > 300_000) return null
const s = input.trim()
if (/<!DOCTYPE|<!ENTITY|<\?xml-stylesheet/i.test(s)) return null
const svgMatch = s.match(/<svg\b([^>]*)>([\s\S]*?)<\/svg>/i)
if (!svgMatch) return null
const attrs = svgMatch[1]
let body = svgMatch[2]
let viewBox = '0 0 300 300'
const vb = attrs.match(/viewBox\s*=\s*["']([^"']+)["']/i)
if (vb) {
viewBox = vb[1].trim()
} else {
const w = attrs.match(/\bwidth\s*=\s*["']([\d.]+)/i)
const h = attrs.match(/\bheight\s*=\s*["']([\d.]+)/i)
if (w && h) viewBox = `0 0 ${w[1]} ${h[1]}`
}
if (!/^[-\d.\s]+$/.test(viewBox)) viewBox = '0 0 300 300'
body = body.replace(/<!--[\s\S]*?-->/g, '')
body = body.replace(DANGEROUS_LOGO_BLOCKS, '')
body = body.replace(DANGEROUS_LOGO_SELFCLOSE, '')
// Drop any element whose tag isn't allowlisted (case-insensitively).
body = body.replace(/<\s*\/?\s*([a-zA-Z][\w:-]*)\b[^>]*>/g, (tag, name: string) =>
ALLOWED_LOGO_ELEMENTS.has(name.toLowerCase()) ? tag : ''
)
// Event handlers: always stripped.
body = body.replace(/\son\w+\s*=\s*"[^"]*"/gi, '')
body = body.replace(/\son\w+\s*=\s*'[^']*'/gi, '')
// href/xlink:href: keep only internal (#id) references — needed for <use>
// and gradient reuse; strip anything external, data:, or javascript:.
body = body.replace(/\s(xlink:)?href\s*=\s*"([^"]*)"/gi, (m, xl, val) =>
val.startsWith('#') ? ` ${xl ?? ''}href="${val}"` : ''
)
body = body.replace(/\s(xlink:)?href\s*=\s*'([^']*)'/gi, (m, xl, val) =>
val.startsWith('#') ? ` ${xl ?? ''}href='${val}'` : ''
)
// style="...": keep only a safe color/opacity property allowlist.
body = body.replace(/\sstyle\s*=\s*"([^"]*)"/gi, (m, val) => {
const safe = sanitizeStyleValue(val)
return safe ? ` style="${safe}"` : ''
})
body = body.replace(/\sstyle\s*=\s*'([^']*)'/gi, (m, val) => {
const safe = sanitizeStyleValue(val)
return safe ? ` style='${safe}'` : ''
})
// fill/stroke/clip-path/mask/filter as plain attributes: block external
// url(...) refs (internal #id refs, hex colors, named colors all pass).
body = body.replace(/\sfilter\s*=\s*["'][^"']*["']/gi, '') // no filter support
for (const attr of ['fill', 'stroke', 'clip-path', 'mask']) {
const re = new RegExp(`\\s${attr}\\s*=\\s*(["'])([^"']*)\\1`, 'gi')
body = body.replace(re, (m, q, val) => (isSafeUrlRef(val) ? m : ''))
}
// Defense in depth — href/url(...) refs above already reject non-# targets,
// so this shouldn't normally match anything, but strip stray javascript:
// schemes if they slipped through in some other attribute.
body = body.replace(/\bjavascript\s*:/gi, '')
body = body.trim()
if (!body) return null
return { viewBox, body }
}
@@ -16,6 +16,18 @@ export interface SeedIcon {
viewBox: string
/** Inner SVG markup — strokes use currentColor, fill none. */
body: string
/**
* Asset kind. Seed/Lucide icons and uploaded line icons are 'icon' (the
* default when omitted) — rendered stroke-only via lib/design/icons.ts.
* Custom color logos (partnerships, acquisitions) are 'logo' — rendered
* full-color, fit to their natural aspect ratio, no stroke normalization.
*/
kind?: 'icon' | 'logo'
/** Public URL for a logo stored in Supabase Storage (kind: 'logo'). */
url?: string
/** Natural pixel dimensions, client-measured at upload (kind: 'logo'). */
width?: number
height?: number
}
export const SEED_ICONS: SeedIcon[] = [
+87 -7
View File
@@ -9,21 +9,52 @@ import { getSupabase, getSupabaseAdmin } from '@/lib/supabase/server'
* writes use the admin (secret) client through our server route, so the
* publishable key stays read-only. Everything degrades to seed-only when the
* project isn't configured.
*
* Two asset kinds share this table (brief follow-up — custom color logos):
* - 'icon' (default): line art, inline SVG `body`, rendered stroke-only.
* - 'logo': full-color partner/acquisition logos. Stored as a file in the
* og-assets Storage bucket (`storage_path`), not inline — may be raster.
* `width`/`height` (client-measured at upload) drive aspect-correct
* rendering. Rendered as-is, no stroke normalization.
*/
const OG_ASSETS_BUCKET = 'og-assets'
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
interface AssetRow {
name: string
label: string
tags: string[] | null
kind: string | null
view_box: string
body: string | null
storage_path: string | null
width: number | null
height: number | null
}
/** Public URL for a Storage object, without an extra network round-trip. */
function publicStorageUrl(path: string): string | undefined {
if (!SUPABASE_URL) return undefined
return `${SUPABASE_URL}/storage/v1/object/public/${OG_ASSETS_BUCKET}/${path}`
}
function rowToIcon(r: AssetRow): SeedIcon {
return { name: r.name, label: r.label, tags: r.tags ?? [], viewBox: r.view_box, body: r.body ?? '' }
const kind = r.kind === 'logo' ? 'logo' : 'icon'
return {
name: r.name,
label: r.label,
tags: r.tags ?? [],
viewBox: r.view_box,
body: r.body ?? '',
kind,
url: r.storage_path ? publicStorageUrl(r.storage_path) : undefined,
width: r.width ?? undefined,
height: r.height ?? undefined,
}
}
const COLUMNS = 'name,label,tags,view_box,body'
const COLUMNS = 'name,label,tags,kind,view_box,body,storage_path,width,height'
/** Uploaded assets, newest first. Returns [] when unconfigured or on error. */
export async function listAssets(): Promise<SeedIcon[]> {
@@ -35,21 +66,23 @@ export async function listAssets(): Promise<SeedIcon[]> {
.select(COLUMNS)
.order('created_at', { ascending: false })
if (error || !data) return []
return (data as AssetRow[]).filter((r) => r.body).map(rowToIcon)
return (data as AssetRow[]).filter((r) => r.body || r.storage_path).map(rowToIcon)
} catch {
return []
}
}
/** Resolve an icon by name: bundled seed first, then an uploaded asset. */
/** Resolve an icon/logo by name: bundled seed first, then an uploaded asset. */
export async function resolveIcon(name: string): Promise<SeedIcon | null> {
if (ICON_MAP[name]) return ICON_MAP[name]
const supabase = getSupabase()
if (!supabase) return null
try {
const { data, error } = await supabase.from('assets').select(COLUMNS).eq('name', name).maybeSingle()
if (error || !data || !(data as AssetRow).body) return null
return rowToIcon(data as AssetRow)
if (error || !data) return null
const row = data as AssetRow
if (!row.body && !row.storage_path) return null
return rowToIcon(row)
} catch {
return null
}
@@ -63,7 +96,7 @@ export interface NewAsset {
body: string
}
/** Insert an uploaded asset (admin/secret key). Throws 'NO_ADMIN' if unconfigured. */
/** Insert an uploaded line icon (admin/secret key). Throws 'NO_ADMIN' if unconfigured. */
export async function insertAsset(a: NewAsset): Promise<SeedIcon> {
const admin = getSupabaseAdmin()
if (!admin) throw new Error('NO_ADMIN')
@@ -75,3 +108,50 @@ export async function insertAsset(a: NewAsset): Promise<SeedIcon> {
if (error || !data) throw new Error(error?.message ?? 'insert failed')
return rowToIcon(data as AssetRow)
}
export interface NewLogo {
name: string
label: string
tags: string[]
/** Raw file bytes — sanitized SVG text (as a Buffer) or the original raster bytes. */
fileBody: Buffer
contentType: string
/** File extension without the dot, e.g. 'svg', 'png'. */
ext: string
/** Natural pixel size, measured client-side before upload. */
width: number
height: number
}
/**
* Upload + insert a color logo (admin/secret key). Stores the file in the
* og-assets Storage bucket rather than inline, so it works for raster too.
* Throws 'NO_ADMIN' if unconfigured.
*/
export async function insertLogoAsset(a: NewLogo): Promise<SeedIcon> {
const admin = getSupabaseAdmin()
if (!admin) throw new Error('NO_ADMIN')
const path = `logos/${a.name}.${a.ext}`
const { error: uploadError } = await admin.storage
.from(OG_ASSETS_BUCKET)
.upload(path, a.fileBody, { contentType: a.contentType, upsert: false })
if (uploadError) throw new Error(uploadError.message)
const { data, error } = await admin
.from('assets')
.insert({
name: a.name,
label: a.label,
tags: a.tags,
kind: 'logo',
view_box: `0 0 ${a.width} ${a.height}`,
storage_path: path,
width: a.width,
height: a.height,
})
.select(COLUMNS)
.single()
if (error || !data) throw new Error(error?.message ?? 'insert failed')
return rowToIcon(data as AssetRow)
}
@@ -0,0 +1,12 @@
-- Custom color logos (partnerships, acquisitions, co-marketing) — follow-up to
-- 0001_init.sql. Logos render in their ORIGINAL colors (no stroke
-- normalization, unlike line icons) via `assets.kind = 'logo'`, stored in the
-- og-assets Storage bucket (already created in 0001) rather than inline, since
-- they may be raster. width/height are captured at upload time (client-measured
-- natural pixel size) so the renderer can fit them without distortion.
--
-- Apply in the Supabase dashboard → SQL Editor. Idempotent.
alter table public.assets
add column if not exists width integer,
add column if not exists height integer;