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} +