/** * Image-sticker manifest. * * Stickers are PNG/WebP/JPG/SVG files dropped into `public/stickers/` with a * specific filename convention: * * __. * * Examples: * hearts__pink_heart.png → category: Hearts, name: Pink Heart * pets__golden_retriever.png → category: Pets, name: Golden Retriever * * Adding a sticker is zero-config: drop the file in `public/stickers/`, name * it correctly, refresh the page. No imports to edit, no manifest to update. * * The filename list comes from a Vite virtual module (see * `stickerManifestPlugin` in vite.config.js). Vite's import.meta.glob can't * see `public/`, so the plugin reads the directory at build time and emits * the list. URLs are stable and unhashed (`/stickers/`), which * lets the server also read these files during export — see * `resolveImageSource` in server.js for that path. * * If `public/stickers/` is empty, this module exports empty arrays and the * StickersTab renders a friendly empty state. No crash, no noisy console. */ import { STICKER_FILES } from 'virtual:sticker-manifest'; // Public URL base for sticker assets. The Vite plugin watches the matching // directory on disk; the server reads from the same prefix on its side. const STICKER_URL_PREFIX = '/stickers/'; /** * Parse a filename into display metadata. * * Filename shape: `[-]__.` where: * - (optional) leading "-" prefix used only for category ordering * - lowercase word, no underscores within * - one or more underscore-separated words * * Returns null for files that don't match (e.g. files lacking `__`). Returning * null rather than throwing lets the module evaluate cleanly even if someone * accidentally drops a stray file into the folder. */ function parseStickerFilename(filename) { const dotIdx = filename.lastIndexOf('.'); if (dotIdx <= 0) return null; const base = filename.slice(0, dotIdx); // Optional "-" prefix for explicit ordering. let sortKey = ''; let rest = base; const dashMatch = base.match(/^(\d+)-(.+)$/); if (dashMatch) { // Pad so '2' sorts before '10' lexicographically. sortKey = dashMatch[1].padStart(6, '0'); rest = dashMatch[2]; } // Split on the DOUBLE underscore. Exactly one expected; if missing or // duplicated, the file isn't a valid sticker. const parts = rest.split('__'); if (parts.length !== 2) return null; const [categorySlug, nameSlug] = parts; if (!categorySlug || !nameSlug) return null; return { categoryId: categorySlug.toLowerCase(), categoryLabel: titleCase(categorySlug), name: titleCase(nameSlug), sortKey, }; } function titleCase(slug) { return slug .split('_') .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join(' '); } /** * Build the sticker list. One pass over the filename list from the virtual * module; each valid file becomes a sticker with `{ id, url, name, * categoryId, categoryLabel }`. * * `id` is derived from the URL because the URL is unique per file and stable * enough across reloads to use as a React key. We don't persist sticker ids * anywhere — once a sticker is added to the canvas, the relevant identity is * the canvas element's id, not the source sticker's. */ function buildStickerList() { const out = []; for (const filename of STICKER_FILES) { const parsed = parseStickerFilename(filename); if (!parsed) continue; const url = STICKER_URL_PREFIX + filename; out.push({ id: url, url, name: parsed.name, categoryId: parsed.categoryId, categoryLabel: parsed.categoryLabel, sortKey: parsed.sortKey, }); } return out; } const _all = buildStickerList(); /** * All sticker entries, sorted by (category sortKey, sticker name). The * category sort key is derived from the lowest `sortKey` seen for that * category — if any file in the category has a `-` prefix, the * whole category gets ordered by it; otherwise alphabetical. */ export const STICKERS = (() => { // Resolve a stable sort key per category: lowest seen `sortKey` (already // zero-padded) for files in that category. Falls back to the category // label for purely alphabetical ordering. const catKey = new Map(); for (const s of _all) { const existing = catKey.get(s.categoryId); if (!existing || (s.sortKey && (!existing.sortKey || s.sortKey < existing.sortKey))) { catKey.set(s.categoryId, { sortKey: s.sortKey || '', label: s.categoryLabel }); } } return [..._all].sort((a, b) => { const ka = catKey.get(a.categoryId); const kb = catKey.get(b.categoryId); const keyA = (ka.sortKey || 'zzz') + '|' + ka.label.toLowerCase(); const keyB = (kb.sortKey || 'zzz') + '|' + kb.label.toLowerCase(); if (keyA !== keyB) return keyA < keyB ? -1 : 1; return a.name.localeCompare(b.name); }); })(); /** * Deduped list of categories, in display order. Each entry is * `{ id, label }`. The Stickers tab adds an implicit "All" pill at the * front itself — that's a tab concern, not a data one. */ export const STICKER_CATEGORIES = (() => { const seen = new Set(); const out = []; for (const s of STICKERS) { if (seen.has(s.categoryId)) continue; seen.add(s.categoryId); out.push({ id: s.categoryId, label: s.categoryLabel }); } return out; })();