"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import styles from "./catalog.module.css"; import { REPO_RAW, SET_URL } from "./set"; /** * The whole OpenEmoji set, filterable. * * Reads the set's own openemoji.json, which lists every emoji Unicode has, * drawn or not (an entry without files is listed, not drawn), so this page is * complete from the first glyph and fills in as the rest are drawn. * * Filters live in the query string, so a filtered view is a link. */ type Emoji = { key: string; char: string; name: string; group: string; 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; unicode: string; license: string; ai_model?: string; sizes: number[]; webp_sizes?: number[]; coverage: { total: number; drawn: number }; emoji: Emoji[]; }; /** Key, swatch colour, label. Colour dots, not 🏻: a box with no emoji font is no swatch at all. */ const TONES: Array<[string, string, string]> = [ ["1f3fb", "#f7d7c4", "light"], ["1f3fc", "#dfb896", "medium-light"], ["1f3fd", "#bb8e67", "medium"], ["1f3fe", "#8d5e3f", "medium-dark"], ["1f3ff", "#5b3a2c", "dark"] ]; const TONE_KEYS = new Set(TONES.map(([key]) => key)); const tonesOf = (e: Emoji): string[] => e.key.split("-").filter((cp) => TONE_KEYS.has(cp)); type Filters = { q: string; group: string; sub: string; /** "" = default (no tone), a tone key, or "all" for every variant. */ tone: string; version: string; 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, platform: "", pack: "" }; const PAGE = 360; function readFilters(): Filters { const params = new URLSearchParams(window.location.search); const size = Number(params.get("size")); const status = params.get("status"); const sort = params.get("sort"); return { q: params.get("q") ?? "", group: params.get("group") ?? "", sub: params.get("sub") ?? "", tone: params.get("tone") ?? "", 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, platform: params.get("platform") ?? "", pack: params.get("pack") ?? "" }; } function writeFilters(f: Filters, selected: string | null): void { const params = new URLSearchParams(); if (f.q) params.set("q", f.q); if (f.group) params.set("group", f.group); if (f.sub) params.set("sub", f.sub); if (f.tone) params.set("tone", f.tone); if (f.version) params.set("v", f.version); 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); } const compareVersions = (a: string, b: string): number => { const [a1 = 0, a2 = 0] = a.split(".").map(Number); const [b1 = 0, b2 = 0] = b.split(".").map(Number); return a1 - b1 || a2 - b2; }; const codepoints = (key: string): string => key .split("-") .map((cp) => `U+${cp.toUpperCase()}`) .join(" "); /** Lowercased haystack per entry, built once. */ const haystackOf = (e: Emoji): string => [e.name, e.char, e.key, codepoints(e.key), e.group, e.subgroup, ...(e.keywords ?? [])].join(" ").toLowerCase(); const fill = (template: string, size: number): string => template.replace("{size}", String(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(""); const [shown, setShown] = useState(PAGE); const [selected, setSelected] = useState(null); const [ready, setReady] = useState(false); const sentinel = useRef(null); useEffect(() => { const initial = readFilters(); setFilters(initial); setQuery(initial.q); setSelected(new URLSearchParams(window.location.search).get("e")); setReady(true); fetch(`${SET_URL}/openemoji.json`) .then((response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json() as Promise; }) .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. useEffect(() => { const timer = window.setTimeout(() => setFilters((f) => (f.q === query ? f : { ...f, q: query })), 140); return () => window.clearTimeout(timer); }, [query]); useEffect(() => { if (ready) writeFilters(filters, selected); }, [filters, selected, ready]); useEffect(() => setShown(PAGE), [filters]); const index = useMemo(() => { if (!manifest) return null; const byKey = new Map(manifest.emoji.map((e) => [e.key, e])); const variants = new Map(); for (const e of manifest.emoji) if (e.base) variants.set(e.base, [...(variants.get(e.base) ?? []), e]); const hay = new Map(manifest.emoji.map((e) => [e.key, haystackOf(e)])); const order = new Map(manifest.emoji.map((e, i) => [e.key, i])); const groups = [...new Set(manifest.emoji.map((e) => e.group))]; const versions = [...new Set(manifest.emoji.map((e) => e.unicode))].sort(compareVersions).reverse(); return { byKey, variants, hay, order, groups, versions }; }, [manifest]); const set = useCallback((key: K, value: Filters[K]) => { setFilters((f) => { const next = { ...f, [key]: value }; if (key === "group") next.sub = ""; if (key === "platform") next.pack = ""; return next; }); }, []); /** Everything but the tone view: what the group and status counts are taken over. */ const matchesBase = useCallback( (e: Emoji, f: Filters, skip: Partial> = {}): boolean => { if (!index) return false; if (!skip.group && f.group && e.group !== f.group) return false; if (f.sub && e.subgroup !== f.sub) return false; if (f.version && e.unicode !== f.version) return false; if (!skip.status && f.status === "drawn" && !e.png) return false; if (!skip.status && f.status === "todo" && e.png) return false; if (f.q) { const hay = index.hay.get(e.key) ?? ""; for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean)) if (!hay.includes(word)) return false; } return true; }, [index] ); /** * The tone view, the way a keyboard does it: default shows the untoned * glyphs; a tone shows each glyph in that tone where it has one (and as it * is where it has none); "all" shows every variant. */ const toneView = useCallback( (list: Emoji[], tone: string): Emoji[] => { if (!index || tone === "all") return list; if (!tone) return list.filter((e) => tonesOf(e).length === 0); const out: Emoji[] = []; for (const e of list) { const tones = tonesOf(e); if (tones.length > 0) continue; const swap = (index.variants.get(e.key) ?? []).find((v) => { const t = tonesOf(v); return t.length > 0 && t.every((x) => x === tone); }); out.push(swap ?? e); } return out; }, [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 // (a search for "thumbs" still wants 👍🏽), so filter the toneless view // first and swap after, then re-check the status filter on the swap. const base = manifest.emoji.filter((e) => matchesBase(e, { ...filters, status: "" })); 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, networkKeys]); const groupCounts = useMemo(() => { const counts = new Map(); if (!manifest) return counts; const list = toneView( manifest.emoji.filter((e) => matchesBase(e, filters, { group: true })), filters.tone ); 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, networkKeys]); const subgroups = useMemo(() => { if (!manifest || !filters.group) return []; return [...new Set(manifest.emoji.filter((e) => e.group === filters.group).map((e) => e.subgroup))]; }, [manifest, filters.group]); // Render in pages as the reader scrolls: 4,000 tiles at once is a slow first paint. useEffect(() => { const node = sentinel.current; if (!node) return; const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) setShown((n) => n + PAGE); }, { rootMargin: "800px" } ); observer.observe(node); return () => observer.disconnect(); }, [results.length]); const current = selected && index ? (index.byKey.get(selected) ?? null) : null; // Arrow keys walk the results while the detail panel is open; Escape closes it. useEffect(() => { if (!current) return; const onKey = (event: KeyboardEvent): void => { if (event.key === "Escape") setSelected(null); if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return; const at = results.findIndex((e) => e.key === current.key); if (at === -1) return; const next = results[at + (event.key === "ArrowRight" ? 1 : -1)]; if (next) { event.preventDefault(); setSelected(next.key); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [current, results]); if (error) { return

The set could not be loaded ({error}).

; } if (!manifest || !index) { return

Loading the set…

; } const drawnPct = Math.round((manifest.coverage.drawn / manifest.coverage.total) * 100); const active = filters.q || filters.group || filters.sub || filters.tone || filters.version || filters.status || filters.sort !== "unicode"; return (
{manifest.coverage.drawn.toLocaleString()} of {manifest.coverage.total.toLocaleString()} drawn · Unicode{" "} {manifest.unicode} · {manifest.license} · set {manifest.version}
{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}
setQuery(event.target.value)} aria-label="Search emoji" />
{index.groups.map((g) => ( ))}
Skin tone {TONES.map(([key, swatch, label]) => ( ))} {results.length.toLocaleString()} {results.length === 1 ? "emoji" : "emoji"} {active ? ( ) : null}
{results.length === 0 ? (

Nothing matches. Try fewer words, or reset the filters.

) : (
    {results.slice(0, shown).map((e) => (
  • ))}
)} {shown < results.length ?
: null} {current ? ( { setQuery(word); setSelected(null); }} onClose={() => setSelected(null)} /> ) : null}
); } function NetworkPanel({ network, repoRaw, activePack, onPack, onHighlight }: { network: Network; repoRaw: string; activePack: string; onPack: (id: string) => 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, onKeyword, onClose }: { emoji: Emoji; network: Network | null; nameOnNetwork: string | null; variants: Emoji[]; base: Emoji | null; onSelect: (key: string) => void; onKeyword: (word: string) => void; onClose: () => void; }): ReactNode { const family = base ? [base, ...variants] : variants.length ? [emoji, ...variants] : []; const imgTag = `${emoji.char}`; return ( ); }