Files
apparel-designer/src/constants/stickers.js
2026-05-23 03:28:58 -05:00

155 lines
5.4 KiB
JavaScript

/**
* Image-sticker manifest.
*
* Stickers are PNG/WebP/JPG/SVG files dropped into `public/stickers/` with a
* specific filename convention:
*
* <category>__<sticker_name>.<ext>
*
* 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/<filename>`), 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: `[<sort>-]<category>__<name>.<ext>` where:
* - <sort> (optional) leading "<digits>-" prefix used only for category ordering
* - <category> lowercase word, no underscores within
* - <name> 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 "<digits>-" 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 `<digits>-` 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;
})();