Major update for v1 and tests
This commit is contained in:
@@ -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;
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user