mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 12:23:50 +00:00
OpenEmoji catalog: every emoji in the reference set, with search and filters
/openemoji/catalog reads the set's openemoji.json (every Unicode emoji, drawn or not) and filters by name and CLDR keyword, group, subgroup, skin tone (keyboard-style swap), Emoji version and status, with sort, tile size, URL-synced filters, paged rendering and a detail panel (codepoints, tone family, copy, PNG/SVG downloads, keyword chips). Spec: an entry without files is listed, not drawn. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6d1bcd0e05
commit
8a6750d373
6 changed files with 1108 additions and 2 deletions
449
apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css
Normal file
449
apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
.catalog {
|
||||
--tile: 56px;
|
||||
--line: #e3e6e0;
|
||||
--muted: #5b6b7a;
|
||||
--accent: #0a7d59;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: var(--muted);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
height: 1.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: linear-gradient(90deg, #d8f0e5, #b6e3cf);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.progress span {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0.35rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
color: #2c3a45;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 0 0.8rem;
|
||||
background: #f6f7f4;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 0.7rem 0.95rem;
|
||||
border: 1px solid #cfd5cc;
|
||||
border-radius: 0.6rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.search:focus,
|
||||
.controls select:focus,
|
||||
.chip:focus-visible,
|
||||
.chipOn:focus-visible,
|
||||
.tile:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.controls select {
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #101418;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid #cfd5cc;
|
||||
border-radius: 0.45rem;
|
||||
background: #fff;
|
||||
min-width: 9rem;
|
||||
}
|
||||
|
||||
.sizeControl input {
|
||||
width: 9rem;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.chip,
|
||||
.chipOn {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.84rem;
|
||||
border: 1px solid #cfd5cc;
|
||||
background: #fff;
|
||||
color: #101418;
|
||||
}
|
||||
|
||||
.chip small,
|
||||
.chipOn small {
|
||||
color: var(--muted);
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chipOn {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chipOn small {
|
||||
color: #d7f3e8;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.reset {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(calc(var(--tile) + 2.4rem), 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.tile {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.6rem 0.3rem 0.45rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.7rem;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
border-color 0.12s ease,
|
||||
box-shadow 0.12s ease;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: calc(var(--tile) + 2.6rem);
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #b9c9bf;
|
||||
box-shadow: 0 6px 16px rgb(16 20 24 / 8%);
|
||||
}
|
||||
|
||||
.tile img {
|
||||
width: var(--tile);
|
||||
height: var(--tile);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.tileOn {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.todo {
|
||||
background: #fafbf8;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--tile);
|
||||
height: var(--tile);
|
||||
font-size: calc(var(--tile) * 0.72);
|
||||
line-height: 1;
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.4);
|
||||
}
|
||||
|
||||
.label {
|
||||
max-width: 100%;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
width: min(24rem, 100vw);
|
||||
overflow-y: auto;
|
||||
padding: 1.4rem 1.3rem 2rem;
|
||||
background: #fff;
|
||||
border-left: 1px solid var(--line);
|
||||
box-shadow: -18px 0 40px rgb(16 20 24 / 12%);
|
||||
animation: slide 0.18s ease;
|
||||
}
|
||||
|
||||
@keyframes slide {
|
||||
from {
|
||||
transform: translateX(24px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.8rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
background: #f1f3ef;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.9rem;
|
||||
background: radial-gradient(circle at 30% 20%, #ffffff, #eef1ec);
|
||||
}
|
||||
|
||||
.heroFallback {
|
||||
font-size: 7rem;
|
||||
line-height: 1;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.native {
|
||||
font-size: 1.6rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.detail h3 {
|
||||
margin: 1rem 0 0.3rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.detail h3::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.todoNote {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.3rem 0.9rem;
|
||||
margin: 0.8rem 0;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.facts dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.facts dd {
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.family {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin: 0.4rem 0 0.9rem;
|
||||
}
|
||||
|
||||
.familyItem,
|
||||
.familyOn {
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.familyOn {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.downloads {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.8rem;
|
||||
font-size: 0.86rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.downloads span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.keywords button {
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: #f1f3ef;
|
||||
font-size: 0.78rem;
|
||||
color: #2c3a45;
|
||||
}
|
||||
|
||||
.keywords button:hover {
|
||||
background: #e2ece6;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.toolbar {
|
||||
position: static;
|
||||
}
|
||||
.row[role="group"][aria-label="Group"] {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.row[role="group"][aria-label="Group"] > * {
|
||||
flex: none;
|
||||
}
|
||||
.controls select {
|
||||
min-width: 0;
|
||||
}
|
||||
.controls label {
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
.count {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
border-radius: 999px;
|
||||
vertical-align: -0.15rem;
|
||||
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 15%);
|
||||
}
|
||||
|
||||
.intro {
|
||||
max-width: 52rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.intro h2 {
|
||||
margin: 0.2rem 0 0.5rem;
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
margin: 0;
|
||||
color: #41505d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.copy {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #cfd5cc;
|
||||
background: #fff;
|
||||
color: #101418;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.copy:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
597
apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx
Normal file
597
apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
"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[];
|
||||
base?: string;
|
||||
png?: string;
|
||||
svg?: string;
|
||||
webp?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const DEFAULTS: Filters = { q: "", group: "", sub: "", tone: "", version: "", status: "", sort: "unicode", size: 56 };
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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 (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<Manifest | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<Filters>(DEFAULTS);
|
||||
const [query, setQuery] = useState("");
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const sentinel = useRef<HTMLDivElement | null>(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<Manifest>;
|
||||
})
|
||||
.then(setManifest)
|
||||
.catch((reason: Error) => setError(reason.message));
|
||||
}, []);
|
||||
|
||||
// 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<string, Emoji[]>();
|
||||
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(<K extends keyof Filters>(key: K, value: Filters[K]) => {
|
||||
setFilters((f) => {
|
||||
const next = { ...f, [key]: value };
|
||||
if (key === "group") next.sub = "";
|
||||
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<Record<"group" | "status", boolean>> = {}): 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 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 (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]);
|
||||
|
||||
const groupCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
if (!manifest) return counts;
|
||||
const list = toneView(
|
||||
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);
|
||||
return counts;
|
||||
}, [manifest, filters, matchesBase, toneView]);
|
||||
|
||||
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 <p className={styles.notice}>The set could not be loaded ({error}).</p>;
|
||||
}
|
||||
if (!manifest || !index) {
|
||||
return <p className={styles.notice}>Loading the set…</p>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.catalog} style={{ ["--tile" as string]: `${filters.size}px` }}>
|
||||
<div className={styles.progress} aria-label={`${manifest.coverage.drawn} of ${manifest.coverage.total} drawn`}>
|
||||
<div className={styles.progressBar} style={{ width: `${drawnPct}%` }} />
|
||||
<span>
|
||||
{manifest.coverage.drawn.toLocaleString()} of {manifest.coverage.total.toLocaleString()} drawn · Unicode{" "}
|
||||
{manifest.unicode} · {manifest.license} · set {manifest.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<input
|
||||
className={styles.search}
|
||||
type="search"
|
||||
placeholder="Search 3,963 emoji: name, keyword, lol, U+1F600…"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label="Search emoji"
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
<label>
|
||||
<span>Subgroup</span>
|
||||
<select value={filters.sub} onChange={(e) => set("sub", e.target.value)} disabled={!filters.group}>
|
||||
<option value="">{filters.group ? "All subgroups" : "Pick a group"}</option>
|
||||
{subgroups.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.replace(/-/g, " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Emoji version</span>
|
||||
<select value={filters.version} onChange={(e) => set("version", e.target.value)}>
|
||||
<option value="">Any</option>
|
||||
{index.versions.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
New in {v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select value={filters.status} onChange={(e) => set("status", e.target.value as Filters["status"])}>
|
||||
<option value="">Drawn and not yet</option>
|
||||
<option value="drawn">Drawn</option>
|
||||
<option value="todo">Not drawn yet</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Sort</span>
|
||||
<select value={filters.sort} onChange={(e) => set("sort", e.target.value as Filters["sort"])}>
|
||||
<option value="unicode">Unicode order</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
<option value="newest">Newest first</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.sizeControl}>
|
||||
<span>Size {filters.size}px</span>
|
||||
<input
|
||||
type="range"
|
||||
min={32}
|
||||
max={128}
|
||||
step={8}
|
||||
value={filters.size}
|
||||
onChange={(e) => set("size", Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.row} role="group" aria-label="Group">
|
||||
<button
|
||||
type="button"
|
||||
className={!filters.group ? styles.chipOn : styles.chip}
|
||||
onClick={() => set("group", "")}
|
||||
>
|
||||
All groups
|
||||
</button>
|
||||
{index.groups.map((g) => (
|
||||
<button
|
||||
type="button"
|
||||
key={g}
|
||||
className={filters.group === g ? styles.chipOn : styles.chip}
|
||||
onClick={() => set("group", filters.group === g ? "" : g)}
|
||||
>
|
||||
{g} <small>{(groupCounts.get(g) ?? 0).toLocaleString()}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.row} role="group" aria-label="Skin tone">
|
||||
<span className={styles.rowLabel}>Skin tone</span>
|
||||
<button type="button" className={!filters.tone ? styles.chipOn : styles.chip} onClick={() => set("tone", "")}>
|
||||
Default
|
||||
</button>
|
||||
{TONES.map(([key, swatch, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
title={`${label} skin tone`}
|
||||
aria-label={`${label} skin tone`}
|
||||
className={filters.tone === key ? styles.chipOn : styles.chip}
|
||||
onClick={() => set("tone", filters.tone === key ? "" : key)}
|
||||
>
|
||||
<span className={styles.swatch} style={{ background: swatch }} />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={filters.tone === "all" ? styles.chipOn : styles.chip}
|
||||
onClick={() => set("tone", filters.tone === "all" ? "" : "all")}
|
||||
>
|
||||
Every variant
|
||||
</button>
|
||||
<span className={styles.count}>
|
||||
{results.length.toLocaleString()} {results.length === 1 ? "emoji" : "emoji"}
|
||||
{active ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.reset}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setFilters({ ...DEFAULTS, size: filters.size });
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<p className={styles.notice}>Nothing matches. Try fewer words, or reset the filters.</p>
|
||||
) : (
|
||||
<ul className={styles.grid}>
|
||||
{results.slice(0, shown).map((e) => (
|
||||
<li key={e.key}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tile} ${e.png ? "" : styles.todo} ${selected === e.key ? styles.tileOn : ""}`}
|
||||
onClick={() => setSelected(e.key)}
|
||||
title={e.png ? e.name : `${e.name} (not drawn yet)`}
|
||||
>
|
||||
{e.webp ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`${SET_URL}/${fill(e.webp, 128)}`}
|
||||
alt={e.char}
|
||||
width={filters.size}
|
||||
height={filters.size}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.fallback} aria-label={e.name}>
|
||||
{e.char}
|
||||
</span>
|
||||
)}
|
||||
{filters.size >= 48 ? <span className={styles.label}>{e.name}</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{shown < results.length ? <div ref={sentinel} className={styles.sentinel} /> : null}
|
||||
|
||||
{current ? (
|
||||
<Detail
|
||||
emoji={current}
|
||||
variants={index.variants.get(current.base ?? current.key) ?? []}
|
||||
base={current.base ? (index.byKey.get(current.base) ?? null) : null}
|
||||
onSelect={setSelected}
|
||||
onKeyword={(word) => {
|
||||
setQuery(word);
|
||||
setSelected(null);
|
||||
}}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({
|
||||
emoji,
|
||||
variants,
|
||||
base,
|
||||
onSelect,
|
||||
onKeyword,
|
||||
onClose
|
||||
}: {
|
||||
emoji: Emoji;
|
||||
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 = `<img class="openemoji" src="${REPO_RAW}/png/64/${emoji.key}.png" alt="${emoji.char}" title="${emoji.name}">`;
|
||||
return (
|
||||
<aside className={styles.detail} aria-label={`${emoji.name} details`}>
|
||||
<button type="button" className={styles.close} onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
<div className={styles.hero}>
|
||||
{emoji.png ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={`${SET_URL}/${fill(emoji.webp ?? emoji.png, 128)}`} alt={emoji.char} width={128} height={128} />
|
||||
) : (
|
||||
<span className={styles.heroFallback}>{emoji.char}</span>
|
||||
)}
|
||||
<span className={styles.native} title="How your system draws it">
|
||||
{emoji.char}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{emoji.name}</h3>
|
||||
{!emoji.png ? <p className={styles.todoNote}>Not drawn yet. The system emoji stands in until it is.</p> : null}
|
||||
<dl className={styles.facts}>
|
||||
<dt>Key</dt>
|
||||
<dd>
|
||||
<code>{emoji.key}</code>
|
||||
</dd>
|
||||
<dt>Codepoints</dt>
|
||||
<dd>
|
||||
<code>{codepoints(emoji.key)}</code>
|
||||
</dd>
|
||||
<dt>Group</dt>
|
||||
<dd>
|
||||
{emoji.group} › {emoji.subgroup.replace(/-/g, " ")}
|
||||
</dd>
|
||||
<dt>Emoji version</dt>
|
||||
<dd>{emoji.unicode}</dd>
|
||||
</dl>
|
||||
|
||||
{family.length > 1 ? (
|
||||
<div className={styles.family}>
|
||||
{family.map((v) => (
|
||||
<button
|
||||
type="button"
|
||||
key={v.key}
|
||||
className={v.key === emoji.key ? styles.familyOn : styles.familyItem}
|
||||
onClick={() => onSelect(v.key)}
|
||||
title={v.name}
|
||||
>
|
||||
{v.webp ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={`${SET_URL}/${fill(v.webp, 64)}`} alt={v.char} width={32} height={32} />
|
||||
) : (
|
||||
v.char
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.copy} data-copy={emoji.char}>
|
||||
Copy emoji
|
||||
</button>
|
||||
<button type="button" className={styles.copy} data-copy={emoji.key}>
|
||||
Copy key
|
||||
</button>
|
||||
{emoji.png ? (
|
||||
<button type="button" className={styles.copy} data-copy={imgTag}>
|
||||
Copy <img>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{emoji.png ? (
|
||||
<div className={styles.downloads}>
|
||||
<span>Download</span>
|
||||
{[512, 256, 128, 64].map((size) => (
|
||||
<a key={size} href={`${REPO_RAW}/${fill(emoji.png!, size)}`} download>
|
||||
PNG {size}
|
||||
</a>
|
||||
))}
|
||||
{emoji.svg ? (
|
||||
<a href={`${REPO_RAW}/${emoji.svg}`} download>
|
||||
SVG
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{emoji.keywords?.length ? (
|
||||
<div className={styles.keywords}>
|
||||
{emoji.keywords.map((word) => (
|
||||
<button type="button" key={word} onClick={() => onKeyword(word)}>
|
||||
{word}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
37
apps/logicsrc-web/src/app/openemoji/catalog/page.tsx
Normal file
37
apps/logicsrc-web/src/app/openemoji/catalog/page.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { SiteShell } from "@/components/site-shell";
|
||||
import { Catalog } from "./catalog";
|
||||
import { REPO_URL } from "./set";
|
||||
import styles from "./catalog.module.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenEmoji catalog · LogicSRC",
|
||||
description:
|
||||
"Every standard emoji in the OpenEmoji set, 3,963 in Emoji 18.0, drawn by gpt-image-2 as one family. Search by name or CLDR keyword, filter by group, skin tone, Emoji version and status, copy the character or download PNG and SVG.",
|
||||
alternates: { canonical: "/openemoji/catalog" }
|
||||
};
|
||||
|
||||
export default function OpenEmojiCatalogPage(): ReactNode {
|
||||
return (
|
||||
<SiteShell active="OpenEmoji">
|
||||
<div className="band">
|
||||
<div className={styles.intro}>
|
||||
<p className="eyebrow">
|
||||
<Link href="/openemoji">OpenEmoji</Link> reference set
|
||||
</p>
|
||||
<h2>The catalog</h2>
|
||||
<p>
|
||||
Every emoji Unicode lists, drawn as one set. Search by name or by the words people use
|
||||
(“lol” finds 😂), narrow by group, skin tone or Emoji version, and open one for
|
||||
its codepoints, its tone family and the files. Anything not drawn yet shows your
|
||||
system's emoji in its place. The set, its fonts and every size are in{" "}
|
||||
<a href={REPO_URL}>profullstack/openemoji</a>.
|
||||
</p>
|
||||
</div>
|
||||
<Catalog />
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
11
apps/logicsrc-web/src/app/openemoji/catalog/set.ts
Normal file
11
apps/logicsrc-web/src/app/openemoji/catalog/set.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Where the OpenEmoji reference set lives. A plain module, not the client
|
||||
* one: a server component importing a constant from a "use client" file gets
|
||||
* a client reference, not the string.
|
||||
*/
|
||||
|
||||
/** Previews and openemoji.json, served by this site. */
|
||||
export const SET_URL = "/openemoji/set";
|
||||
/** Full-size files and the fonts live in the set's repository. */
|
||||
export const REPO_RAW = "https://raw.githubusercontent.com/profullstack/openemoji/main";
|
||||
export const REPO_URL = "https://github.com/profullstack/openemoji";
|
||||
|
|
@ -105,6 +105,11 @@ export default function OpenEmojiPage(): ReactNode {
|
|||
Status: 0.1. Files first and <code style={mono}>made_by</code> on the work, like{" "}
|
||||
<Link href="/openwiki">OpenWiki</Link>.
|
||||
</p>
|
||||
<p>
|
||||
<Link href="/openemoji/catalog" className="button-primary">
|
||||
Browse the whole set: 3,963 emoji
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="band">
|
||||
|
|
@ -113,7 +118,8 @@ export default function OpenEmojiPage(): ReactNode {
|
|||
<p>
|
||||
From the reference implementation: every glyph drawn by gpt-image-2 under one art
|
||||
direction. The skin tones are edits of their base, which is why the two thumbs are the
|
||||
same thumb.
|
||||
same thumb. <Link href="/openemoji/catalog">The catalog</Link> has all of them, with
|
||||
search and filters.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -276,6 +282,10 @@ emoji build # sizes, SVGs, fonts, manifest`}</pre>
|
|||
<h2>Where everything lives</h2>
|
||||
</div>
|
||||
<ul style={{ color: "#41505d", lineHeight: 1.9, paddingLeft: "1.1rem" }}>
|
||||
<li>
|
||||
<Link href="/openemoji/catalog">Catalog</Link>: every emoji in the reference set, with
|
||||
search, group, skin tone, version and status filters
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/docs/openemoji">Specification</Link>: the shape, eight rules, discovery, the
|
||||
mapping from Mastodon, Slack, Discord and CLDR
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ There are eight, and every one of them degrades rather than fails.
|
|||
|
||||
**3. A custom emoji's key starts with `x-`.** `x-` followed by lowercase letters, digits, `_` or `-`. It has no `char`, one or more `shortcodes` (without colons), a `name`, and `group: "Custom"` unless the set groups its own. A shortcode belongs to its set: two sets may both define `:shipit:`, and an app that loads both decides which wins. No `x-` key may be used for something Unicode has encoded; when Unicode encodes it, the set adds the standard key and may keep the custom one as an alias.
|
||||
|
||||
**4. Files are named by key, and the descriptor says where.** An entry's `png` is a path that may contain `{size}`, filled in from the set's `sizes`; `svg` is a path; a set may add `webp`, `avif` or `apng` the same way. A reader picks the smallest size at least as large as the rendered size times the device pixel ratio, and the largest when none is. Fonts are listed once for the set with their colour format (`cbdt`, `sbix`, `colrv1`, `ot-svg`), because a browser picks among them by what it supports, not per emoji.
|
||||
**4. Files are named by key, and the descriptor says where.** An entry's `png` is a path that may contain `{size}`, filled in from the set's `sizes`; `svg` is a path; a set may add `webp`, `avif` or `apng` the same way. A reader picks the smallest size at least as large as the rendered size times the device pixel ratio, and the largest when none is. An entry with no file paths is listed but not drawn: a set may list every emoji Unicode has while it is still being drawn, and a reader treats such an entry as missing (rule 5). Fonts are listed once for the set with their colour format (`cbdt`, `sbix`, `colrv1`, `ot-svg`), because a browser picks among them by what it supports, not per emoji.
|
||||
|
||||
**5. Coverage is stated, not implied.** `coverage.total` is the number of standard emoji in the Unicode version the set names, `coverage.drawn` how many of them it has, and `coverage.missing` their keys. A reader that meets an emoji the set does not have falls back to the platform's own, never to a blank or a box. A set that draws only faces is a valid set; it only has to say so.
|
||||
|
||||
|
|
@ -134,6 +134,8 @@ Importing from any of them is a fetch and a rename: shortcode to `x-` key, image
|
|||
|
||||
## Reference implementation
|
||||
|
||||
The set it drew is browsable at [/openemoji/catalog](/openemoji/catalog): every emoji, searchable by name and CLDR keyword, filterable by group, skin tone, Emoji version and status. Its files are in [profullstack/openemoji](https://github.com/profullstack/openemoji).
|
||||
|
||||
`emoji` in [profullstack/cli-tools](https://github.com/profullstack/cli-tools) draws a complete set with an image model and packs it as above:
|
||||
|
||||
```sh
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue