155 lines
5.4 KiB
JavaScript
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;
|
|
})();
|