From cbe6b1cfd54cd8201c78dd0b29380866f126d875 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 05:08:49 +0000 Subject: [PATCH] OpenEmoji catalog: browse and install by network Reads platforms.json from the set: a network picker grouped by kind (custom emoji, sticker packs, images to post), and for the chosen network its verified limits, install steps, packs with sizes, Show (in the grid) and Download (release assets), the grid narrowed to what its bundles hold with each emoji's name on that network, and the detail panel saying which pack holds it. A network that names what it is for opens on it: X opens on country flags. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/openemoji/catalog/catalog.module.css | 158 +++++++++++ .../src/app/openemoji/catalog/catalog.tsx | 262 +++++++++++++++++- 2 files changed, 414 insertions(+), 6 deletions(-) diff --git a/apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css b/apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css index 5e4445b..c3cebf7 100644 --- a/apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css +++ b/apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css @@ -447,3 +447,161 @@ border-color: var(--accent); color: var(--accent); } + +.networks { + display: grid; + gap: 0.4rem; + margin-bottom: 0.9rem; +} + +.networkRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} + +.networkRow > span:first-child { + min-width: 7.5rem; +} + +.kind_emoji, +.kind_stickers, +.kind_media { + font-size: 0.66rem; + font-weight: 600; + padding: 0.05rem 0.4rem; + border-radius: 999px; + letter-spacing: 0.02em; +} + +.kind_emoji { + background: #e3f4ec; + color: #0a6a4c; +} + +.kind_stickers { + background: #efe8fb; + color: #5b3aa6; +} + +.kind_media { + background: #fdf0dc; + color: #8a5a00; +} + +.chipOn .kind_emoji, +.chipOn .kind_stickers, +.chipOn .kind_media { + background: rgb(255 255 255 / 22%); + color: #fff; +} + +.panel { + border: 1px solid var(--line); + border-radius: 0.9rem; + background: #fff; + padding: 1rem 1.1rem; + margin-bottom: 1rem; +} + +.panelHead h3 { + margin: 0 0 0.35rem; + font-size: 1.15rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.panelHead p { + margin: 0 0 0.5rem; + color: #41505d; + font-size: 0.9rem; +} + +.highlight { + font-size: 0.86rem; +} + +.linkish { + background: none; + padding: 0; + margin-right: 0.6rem; + color: var(--accent); + font-size: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + +.panelBody { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem 1.4rem; +} + +.steps { + margin: 0; + padding-left: 1.2rem; + font-size: 0.9rem; + line-height: 1.55; + color: #26323c; +} + +.steps li + li { + margin-top: 0.3rem; +} + +.packs table { + width: 100%; + border-collapse: collapse; + font-size: 0.84rem; +} + +.packs th { + text-align: left; + color: var(--muted); + font-weight: 600; + padding: 0 0.5rem 0.35rem 0; + border-bottom: 1px solid var(--line); +} + +.packs td { + padding: 0.35rem 0.5rem 0.35rem 0; + border-bottom: 1px solid #f0f2ee; +} + +.packOn td { + background: #eef8f3; +} + +.packActions { + white-space: nowrap; + text-align: right; +} + +.docs { + grid-column: 1 / -1; + margin: 0; + font-size: 0.8rem; + color: var(--muted); +} + +.onNetwork { + font-size: 0.86rem; + margin: 0 0 0.8rem; + color: #26323c; +} + +@media (max-width: 760px) { + .panelBody { + grid-template-columns: 1fr; + } + .networkRow { + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 0.2rem; + } + .networkRow > * { + flex: none; + } +} diff --git a/apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx b/apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx index c03ed7c..de0473e 100644 --- a/apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx +++ b/apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx @@ -24,12 +24,41 @@ type Emoji = { subgroup: string; unicode: string; keywords?: string[]; + shortcodes?: string[]; base?: string; png?: string; svg?: string; webp?: string; }; +type NetworkPack = { id: string; title: string; subset: string; keys: string[]; file: string; bytes: number }; + +type Network = { + id: string; + name: string; + kind: "emoji" | "stickers" | "media"; + size: number; + format: string; + max_bytes: number; + name_rule?: { max: number; pattern: string; prefix: string }; + packSize?: number; + packsFrom?: string; + highlight?: { subgroups: string[]; why: string }; + limits: string; + steps: string[]; + docs: string[]; + packs: NetworkPack[]; + renamed: Record; +}; + +type Networks = { generated: string; download_base: string; platforms: Network[] }; + +const KIND_LABEL: Record = { + emoji: "Custom emoji", + stickers: "Sticker packs", + media: "Images to post" +}; + type Manifest = { name: string; version: string; @@ -63,9 +92,23 @@ type Filters = { status: "" | "drawn" | "todo"; sort: "unicode" | "name" | "newest"; size: number; + /** A network id from platforms.json, and optionally one of its packs. */ + platform: string; + pack: string; }; -const DEFAULTS: Filters = { q: "", group: "", sub: "", tone: "", version: "", status: "", sort: "unicode", size: 56 }; +const DEFAULTS: Filters = { + q: "", + group: "", + sub: "", + tone: "", + version: "", + status: "", + sort: "unicode", + size: 56, + platform: "", + pack: "" +}; const PAGE = 360; function readFilters(): Filters { @@ -81,7 +124,9 @@ function readFilters(): Filters { version: params.get("v") ?? "", status: status === "drawn" || status === "todo" ? status : "", sort: sort === "name" || sort === "newest" ? sort : "unicode", - size: size >= 32 && size <= 128 ? size : DEFAULTS.size + size: size >= 32 && size <= 128 ? size : DEFAULTS.size, + platform: params.get("platform") ?? "", + pack: params.get("pack") ?? "" }; } @@ -95,6 +140,8 @@ function writeFilters(f: Filters, selected: string | null): void { if (f.status) params.set("status", f.status); if (f.sort !== "unicode") params.set("sort", f.sort); if (f.size !== DEFAULTS.size) params.set("size", String(f.size)); + if (f.platform) params.set("platform", f.platform); + if (f.pack) params.set("pack", f.pack); if (selected) params.set("e", selected); const query = params.toString(); window.history.replaceState(null, "", query ? `?${query}` : window.location.pathname); @@ -120,6 +167,7 @@ const fill = (template: string, size: number): string => template.replace("{size export function Catalog(): ReactNode { const [manifest, setManifest] = useState(null); + const [networks, setNetworks] = useState(null); const [error, setError] = useState(null); const [filters, setFilters] = useState(DEFAULTS); const [query, setQuery] = useState(""); @@ -141,6 +189,11 @@ export function Catalog(): ReactNode { }) .then(setManifest) .catch((reason: Error) => setError(reason.message)); + // The per-network bundles are optional: without them the catalog still works. + fetch(`${SET_URL}/platforms.json`) + .then((response) => (response.ok ? (response.json() as Promise) : null)) + .then(setNetworks) + .catch(() => setNetworks(null)); }, []); // The search box types freely; the filter follows a beat behind. @@ -171,6 +224,7 @@ export function Catalog(): ReactNode { setFilters((f) => { const next = { ...f, [key]: value }; if (key === "group") next.sub = ""; + if (key === "platform") next.pack = ""; return next; }); }, []); @@ -217,6 +271,27 @@ export function Catalog(): ReactNode { [index] ); + const network = useMemo( + () => networks?.platforms.find((p) => p.id === filters.platform) ?? null, + [networks, filters.platform] + ); + + /** What the chosen network's bundles hold (or one pack of them); null when no network is chosen. */ + const networkKeys = useMemo(() => { + if (!network) return null; + const packs = filters.pack ? network.packs.filter((p) => p.id === filters.pack) : network.packs; + return new Set(packs.flatMap((p) => p.keys)); + }, [network, filters.pack]); + + /** The name an emoji goes by on the chosen network. */ + const nameOn = useCallback( + (e: Emoji): string | null => { + if (!network?.name_rule) return null; + return network.renamed[e.key] ?? e.shortcodes?.[0] ?? null; + }, + [network] + ); + const results = useMemo(() => { if (!manifest || !index) return []; // Tone swaps come from variants the other filters may have excluded @@ -226,13 +301,21 @@ export function Catalog(): ReactNode { let list = toneView(base, filters.tone); if (filters.status === "drawn") list = list.filter((e) => e.png); if (filters.status === "todo") list = list.filter((e) => !e.png); + if (networkKeys) { + // A network's bundles decide; with a tone chosen, a variant outside + // them falls back to its base when the base is in. + list = list + .map((e) => (networkKeys.has(e.key) ? e : e.base && networkKeys.has(e.base) ? index.byKey.get(e.base)! : null)) + .filter((e): e is Emoji => Boolean(e)); + list = [...new Map(list.map((e) => [e.key, e])).values()]; + } if (filters.sort === "name") list = [...list].sort((a, b) => a.name.localeCompare(b.name)); if (filters.sort === "newest") list = [...list].sort( (a, b) => compareVersions(b.unicode, a.unicode) || (index.order.get(a.key) ?? 0) - (index.order.get(b.key) ?? 0) ); return list; - }, [manifest, index, filters, matchesBase, toneView]); + }, [manifest, index, filters, matchesBase, toneView, networkKeys]); const groupCounts = useMemo(() => { const counts = new Map(); @@ -241,9 +324,12 @@ export function Catalog(): ReactNode { manifest.emoji.filter((e) => matchesBase(e, filters, { group: true })), filters.tone ); - for (const e of list) counts.set(e.group, (counts.get(e.group) ?? 0) + 1); + for (const e of list) { + if (networkKeys && !networkKeys.has(e.key)) continue; + counts.set(e.group, (counts.get(e.group) ?? 0) + 1); + } return counts; - }, [manifest, filters, matchesBase, toneView]); + }, [manifest, filters, matchesBase, toneView, networkKeys]); const subgroups = useMemo(() => { if (!manifest || !filters.group) return []; @@ -305,6 +391,60 @@ export function Catalog(): ReactNode { + {networks ? ( +
+
+ Install on + +
+ {(["emoji", "stickers", "media"] as const).map((kind) => ( +
+ {KIND_LABEL[kind]} + {networks.platforms + .filter((p) => p.kind === kind) + .map((p) => ( + + ))} +
+ ))} +
+ ) : null} + + {network && networks ? ( + set("pack", filters.pack === id ? "" : id)} + onHighlight={(sub) => { + const e = manifest.emoji.find((x) => x.subgroup === sub); + setFilters((f) => ({ ...f, group: e?.group ?? "", sub })); + }} + /> + ) : null} +
)} - {filters.size >= 48 ? {e.name} : null} + {filters.size >= 48 ? ( + {nameOn(e) ? `:${nameOn(e)}:` : e.name} + ) : null} ))} @@ -466,6 +608,8 @@ export function Catalog(): ReactNode { {current ? ( void; + onHighlight: (subgroup: string) => void; +}): ReactNode { + const mb = (bytes: number) => (bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`); + return ( +
+
+

+ {network.name} {KIND_LABEL[network.kind]} +

+

{network.limits}

+ {network.highlight ? ( +

+ {network.highlight.why}{" "} + {network.highlight.subgroups.map((sub) => ( + + ))} +

+ ) : null} +
+
+
    + {network.steps.map((step) => ( +
  1. {step}
  2. + ))} +
+ {network.packs.length ? ( +
+ + + + + + + + + + {network.packs.map((pack) => ( + + + + + + + ))} + +
Pack{network.kind === "stickers" ? "Stickers" : "Emoji"}Size +
{pack.title}{pack.keys.length.toLocaleString()}{mb(pack.bytes)} + + + Download + +
+
+ ) : null} + {network.docs.length ? ( +

+ Official docs:{" "} + {network.docs.map((url, i) => ( + + {i ? ", " : ""} + + {new URL(url).hostname.replace(/^www\./, "")} + + + ))} +

+ ) : null} +
+
+ ); +} + function Detail({ emoji, + network, + nameOnNetwork, variants, base, onSelect, @@ -489,6 +722,8 @@ function Detail({ onClose }: { emoji: Emoji; + network: Network | null; + nameOnNetwork: string | null; variants: Emoji[]; base: Emoji | null; onSelect: (key: string) => void; @@ -553,6 +788,21 @@ function Detail({
) : null} + {network ? ( +

+ On {network.name}:{" "} + {nameOnNetwork ? :{nameOnNetwork}: : KIND_LABEL[network.kind].toLowerCase()} + {(() => { + const packs = network.packs.filter((p) => p.keys.includes(emoji.key)).map((p) => p.title); + return packs.length ? · in {packs.join(", ")} : · not in a bundle for this network; + })()} +

+ ) : emoji.shortcodes?.[0] ? ( +

+ Shortcode :{emoji.shortcodes[0]}: +

+ ) : null} +