mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 13:17:07 +00:00
OpenEmoji catalog: every emoji in the reference set, with search and filters (#197)
* 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> * OpenEmoji catalog: reference set previews, first 206 glyphs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6d1bcd0e05
commit
f80488ce92
419 changed files with 77688 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue