mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +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> * 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>
597 lines
21 KiB
TypeScript
597 lines
21 KiB
TypeScript
"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>
|
||
);
|
||
}
|