Major update for v1 and tests

This commit is contained in:
khalid@traclabs.com
2026-05-23 03:28:58 -05:00
parent 628a6765f4
commit b1fb6fa3aa
1748 changed files with 27723 additions and 1854 deletions

View File

@@ -1,159 +1,154 @@
export const STICKER_CATEGORIES = ['all', 'faces', 'animals', 'food', 'sports', 'nature', 'objects'];
/**
* 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.
*/
export const STICKERS = [
// Faces
{ emoji: '😀', category: 'faces' },
{ emoji: '😁', category: 'faces' },
{ emoji: '😂', category: 'faces' },
{ emoji: '🤣', category: 'faces' },
{ emoji: '😃', category: 'faces' },
{ emoji: '😄', category: 'faces' },
{ emoji: '😅', category: 'faces' },
{ emoji: '😆', category: 'faces' },
{ emoji: '😉', category: 'faces' },
{ emoji: '😊', category: 'faces' },
{ emoji: '😋', category: 'faces' },
{ emoji: '😎', category: 'faces' },
{ emoji: '😍', category: 'faces' },
{ emoji: '😘', category: 'faces' },
{ emoji: '🥰', category: 'faces' },
{ emoji: '😗', category: 'faces' },
{ emoji: '🤔', category: 'faces' },
{ emoji: '🤨', category: 'faces' },
{ emoji: '🧐', category: 'faces' },
{ emoji: '🤓', category: 'faces' },
{ emoji: '😈', category: 'faces' },
{ emoji: '🤠', category: 'faces' },
{ emoji: '🥳', category: 'faces' },
{ emoji: '🤩', category: 'faces' },
import { STICKER_FILES } from 'virtual:sticker-manifest';
// Animals
{ emoji: '🐶', category: 'animals' },
{ emoji: '🐱', category: 'animals' },
{ emoji: '🐭', category: 'animals' },
{ emoji: '🐹', category: 'animals' },
{ emoji: '🐰', category: 'animals' },
{ emoji: '🦊', category: 'animals' },
{ emoji: '🐻', category: 'animals' },
{ emoji: '🐼', category: 'animals' },
{ emoji: '🐨', category: 'animals' },
{ emoji: '🐯', category: 'animals' },
{ emoji: '🦁', category: 'animals' },
{ emoji: '🐮', category: 'animals' },
{ emoji: '🐷', category: 'animals' },
{ emoji: '🐸', category: 'animals' },
{ emoji: '🐵', category: 'animals' },
{ emoji: '🐔', category: 'animals' },
{ emoji: '🐧', category: 'animals' },
{ emoji: '🐦', category: 'animals' },
{ emoji: '🦄', category: 'animals' },
{ emoji: '🐝', category: 'animals' },
{ emoji: '🦋', category: 'animals' },
{ emoji: '🐌', category: 'animals' },
{ emoji: '🐞', category: 'animals' },
{ emoji: '🐢', category: 'animals' },
// 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/';
// Food
{ emoji: '🍎', category: 'food' },
{ emoji: '🍐', category: 'food' },
{ emoji: '🍊', category: 'food' },
{ emoji: '🍋', category: 'food' },
{ emoji: '🍌', category: 'food' },
{ emoji: '🍉', category: 'food' },
{ emoji: '🍇', category: 'food' },
{ emoji: '🍓', category: 'food' },
{ emoji: '🍈', category: 'food' },
{ emoji: '🍒', category: 'food' },
{ emoji: '🍑', category: 'food' },
{ emoji: '🍍', category: 'food' },
{ emoji: '🥥', category: 'food' },
{ emoji: '🥝', category: 'food' },
{ emoji: '🍅', category: 'food' },
{ emoji: '🥑', category: 'food' },
{ emoji: '🍆', category: 'food' },
{ emoji: '🥔', category: 'food' },
{ emoji: '🥕', category: 'food' },
{ emoji: '🌽', category: 'food' },
{ emoji: '🍕', category: 'food' },
{ emoji: '🍔', category: 'food' },
{ emoji: '🍟', category: 'food' },
{ emoji: '🌭', category: 'food' },
/**
* 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);
// Sports
{ emoji: '⚽', category: 'sports' },
{ emoji: '🏀', category: 'sports' },
{ emoji: '🏈', category: 'sports' },
{ emoji: '⚾', category: 'sports' },
{ emoji: '🥎', category: 'sports' },
{ emoji: '🎾', category: 'sports' },
{ emoji: '🏐', category: 'sports' },
{ emoji: '🏉', category: 'sports' },
{ emoji: '🎱', category: 'sports' },
{ emoji: '🏓', category: 'sports' },
{ emoji: '🏸', category: 'sports' },
{ emoji: '🥅', category: 'sports' },
{ emoji: '⛳', category: 'sports' },
{ emoji: '🥊', category: 'sports' },
{ emoji: '🥋', category: 'sports' },
{ emoji: '🎯', category: 'sports' },
{ emoji: '⛹️', category: 'sports' },
{ emoji: '🚴', category: 'sports' },
{ emoji: '🏆', category: 'sports' },
{ emoji: '🥇', category: 'sports' },
{ emoji: '🥈', category: 'sports' },
{ emoji: '🥉', category: 'sports' },
{ emoji: '🏅', category: 'sports' },
{ emoji: '🎖️', category: 'sports' },
// 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];
}
// Nature
{ emoji: '🌸', category: 'nature' },
{ emoji: '💐', category: 'nature' },
{ emoji: '🌹', category: 'nature' },
{ emoji: '🌺', category: 'nature' },
{ emoji: '🌻', category: 'nature' },
{ emoji: '🌼', category: 'nature' },
{ emoji: '🌷', category: 'nature' },
{ emoji: '🌱', category: 'nature' },
{ emoji: '🌲', category: 'nature' },
{ emoji: '🌳', category: 'nature' },
{ emoji: '🌴', category: 'nature' },
{ emoji: '🌵', category: 'nature' },
{ emoji: '🌾', category: 'nature' },
{ emoji: '🌿', category: 'nature' },
{ emoji: '☘️', category: 'nature' },
{ emoji: '🍀', category: 'nature' },
{ emoji: '🍁', category: 'nature' },
{ emoji: '🍂', category: 'nature' },
{ emoji: '🍃', category: 'nature' },
{ emoji: '🌈', category: 'nature' },
{ emoji: '☀️', category: 'nature' },
{ emoji: '🌙', category: 'nature' },
{ emoji: '⭐', category: 'nature' },
{ emoji: '🔥', category: 'nature' },
// 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;
// Objects
{ emoji: '❤️', category: 'objects' },
{ emoji: '💛', category: 'objects' },
{ emoji: '💚', category: 'objects' },
{ emoji: '💙', category: 'objects' },
{ emoji: '💜', category: 'objects' },
{ emoji: '🧡', category: 'objects' },
{ emoji: '💔', category: 'objects' },
{ emoji: '💯', category: 'objects' },
{ emoji: '✨', category: 'objects' },
{ emoji: '🌟', category: 'objects' },
{ emoji: '💫', category: 'objects' },
{ emoji: '🎵', category: 'objects' },
{ emoji: '🎶', category: 'objects' },
{ emoji: '🎸', category: 'objects' },
{ emoji: '🎺', category: 'objects' },
{ emoji: '🎷', category: 'objects' },
{ emoji: '🎹', category: 'objects' },
{ emoji: '👑', category: 'objects' },
{ emoji: '💎', category: 'objects' },
{ emoji: '🎁', category: 'objects' },
{ emoji: '🎈', category: 'objects' },
{ emoji: '🎉', category: 'objects' },
{ emoji: '🎊', category: 'objects' },
{ emoji: '🔮', category: 'objects' },
];
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;
})();