174 lines
12 KiB
TypeScript
174 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { CSSProperties, useEffect, useMemo, useState } from "react";
|
||
import iconData from "../data/web-icons.json";
|
||
import styles from "./IconLibrary.module.css";
|
||
|
||
type IconItem = (typeof iconData.icons)[number];
|
||
type CodeMode = "character" | "html" | "react" | "css";
|
||
type PreviewBackground = "light" | "soft" | "dark";
|
||
|
||
export default function IconLibraryPage() {
|
||
const [query, setQuery] = useState("");
|
||
const [category, setCategory] = useState("all");
|
||
const [selectedId, setSelectedId] = useState("home");
|
||
const [favorites, setFavorites] = useState<string[]>([]);
|
||
const [view, setView] = useState<"grid" | "list">("grid");
|
||
const [size, setSize] = useState(28);
|
||
const [color, setColor] = useState("#27324a");
|
||
const [background, setBackground] = useState<PreviewBackground>("soft");
|
||
const [codeMode, setCodeMode] = useState<CodeMode>("html");
|
||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||
const [toast, setToast] = useState("");
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const saved = window.localStorage.getItem("orbit-icon-favorites");
|
||
if (saved) setFavorites(JSON.parse(saved));
|
||
} catch { /* use an empty favorite list */ }
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
window.localStorage.setItem("orbit-icon-favorites", JSON.stringify(favorites));
|
||
}, [favorites]);
|
||
|
||
const selected = iconData.icons.find((icon) => icon.id === selectedId) ?? iconData.icons[0];
|
||
const filteredIcons = useMemo(() => iconData.icons.filter((icon) => {
|
||
const text = `${icon.name}${icon.cn}${icon.id}${icon.keywords.join("")}`.toLowerCase();
|
||
const matchesQuery = text.includes(query.toLowerCase());
|
||
const matchesCategory = category === "all" || icon.category === category;
|
||
const matchesFavorite = !onlyFavorites || favorites.includes(icon.id);
|
||
return matchesQuery && matchesCategory && matchesFavorite;
|
||
}), [query, category, onlyFavorites, favorites]);
|
||
|
||
const categoryName = iconData.categories.find((item) => item.id === selected.category)?.name ?? "通用";
|
||
const code = getIconCode(selected, codeMode, size, color);
|
||
const previewStyle = { "--preview-size": `${size}px`, "--preview-color": color } as CSSProperties;
|
||
|
||
const notify = (message: string) => {
|
||
setToast(message);
|
||
window.setTimeout(() => setToast(""), 1800);
|
||
};
|
||
|
||
const copy = async (value: string, label: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(value);
|
||
} catch {
|
||
const input = document.createElement("textarea");
|
||
input.value = value;
|
||
input.style.position = "fixed";
|
||
input.style.opacity = "0";
|
||
document.body.appendChild(input);
|
||
input.select();
|
||
document.execCommand("copy");
|
||
input.remove();
|
||
}
|
||
notify(`${label}已复制`);
|
||
};
|
||
|
||
const toggleFavorite = (id: string) => setFavorites((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
|
||
|
||
const exportFavorites = () => {
|
||
const items = iconData.icons.filter((icon) => favorites.includes(icon.id));
|
||
const blob = new Blob([JSON.stringify(items, null, 2)], { type: "application/json;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = "web-icons-favorites.json";
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
notify("收藏图标已导出");
|
||
};
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<header className={styles.topbar}>
|
||
<Link href="/" className={styles.brand}><span>G</span><strong>GlyphKit</strong><small>WEB ICON LIBRARY</small></Link>
|
||
<nav><Link href="/simple-list">列表模板</Link><Link href="/repeatable-form">可重复表单</Link><Link className={styles.activeNav} href="/icons">图标库</Link><Link href="/markdown">文档编辑</Link></nav>
|
||
<div className={styles.headerActions}><button onClick={() => setOnlyFavorites((value) => !value)} className={onlyFavorites ? styles.favoriteFilterActive : ""}>☆ 收藏 {favorites.length}</button><button onClick={exportFavorites} disabled={favorites.length === 0}>导出收藏</button><span>林</span></div>
|
||
</header>
|
||
|
||
<div className={styles.workspace}>
|
||
<aside className={styles.sidebar}>
|
||
<div className={styles.sidebarTitle}><span>ICON SET</span><strong>网页图标</strong><small>{iconData.icons.length} 个原生符号</small></div>
|
||
<nav className={styles.categories} aria-label="图标分类">
|
||
{iconData.categories.map((item) => {
|
||
const count = item.id === "all" ? iconData.icons.length : iconData.icons.filter((icon) => icon.category === item.id).length;
|
||
return <button key={item.id} className={category === item.id ? styles.categoryActive : ""} onClick={() => { setCategory(item.id); setOnlyFavorites(false); }}><span>{categorySymbol(item.id)}</span>{item.name}<b>{count}</b></button>;
|
||
})}
|
||
</nav>
|
||
<div className={styles.sidebarHelp}><span>i</span><p><strong>零依赖图标</strong><small>复制后可直接用于 HTML、CSS 和 React 页面。</small></p></div>
|
||
</aside>
|
||
|
||
<main className={styles.main}>
|
||
<section className={styles.hero}>
|
||
<div><p>SYMBOL PICKER</p><h1>为界面选择一个合适的图标</h1><span>搜索、预览并复制。全部图标使用系统字体,不需要安装额外资源。</span></div>
|
||
<div className={styles.heroSamples}>{["⌂","✓","⌕","✎","▦"].map((symbol,index) => <span key={symbol} style={{transform:`translateY(${index % 2 ? 7 : -2}px)`}}>{symbol}</span>)}</div>
|
||
</section>
|
||
|
||
<div className={styles.toolbar}>
|
||
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索图标名称、功能或关键词,例如:编辑、upload" />{query && <button onClick={() => setQuery("")}>×</button>}<kbd>⌘ K</kbd></label>
|
||
<div className={styles.sizeControl}><span>预览大小</span><input type="range" min="18" max="42" value={size} onChange={(event) => setSize(Number(event.target.value))} /><b>{size}px</b></div>
|
||
<div className={styles.viewSwitch}><button className={view === "grid" ? styles.viewActive : ""} onClick={() => setView("grid")} aria-label="网格视图">▦</button><button className={view === "list" ? styles.viewActive : ""} onClick={() => setView("list")} aria-label="列表视图">☷</button></div>
|
||
</div>
|
||
|
||
<div className={styles.resultHeading}><div><strong>{onlyFavorites ? "我的收藏" : iconData.categories.find((item) => item.id === category)?.name}</strong><span>{filteredIcons.length} 个结果</span></div>{(query || onlyFavorites || category !== "all") && <button onClick={() => { setQuery(""); setCategory("all"); setOnlyFavorites(false); }}>清除筛选</button>}</div>
|
||
|
||
<div className={`${styles.iconCollection} ${view === "list" ? styles.listView : ""}`} style={previewStyle}>
|
||
{filteredIcons.map((icon) => {
|
||
const favorite = favorites.includes(icon.id);
|
||
return <article key={icon.id} className={selected.id === icon.id ? styles.selectedCard : ""}>
|
||
<button className={styles.selectIcon} onClick={() => setSelectedId(icon.id)}>
|
||
<span className={styles.iconSymbol}>{icon.symbol}</span>
|
||
<span className={styles.iconLabel}><strong>{icon.cn}</strong><small>{icon.name}</small></span>
|
||
</button>
|
||
<button className={`${styles.favoriteButton} ${favorite ? styles.isFavorite : ""}`} onClick={() => toggleFavorite(icon.id)} aria-label={favorite ? `取消收藏 ${icon.cn}` : `收藏 ${icon.cn}`}>{favorite ? "★" : "☆"}</button>
|
||
<button className={styles.quickCopy} onClick={() => copy(icon.symbol, "图标字符")} aria-label={`复制 ${icon.cn}`}>复制</button>
|
||
</article>;
|
||
})}
|
||
{filteredIcons.length === 0 && <div className={styles.empty}><span>⌕</span><h3>没有找到匹配图标</h3><p>尝试搜索“首页”“编辑”“文件”或英文关键词。</p><button onClick={() => { setQuery(""); setCategory("all"); setOnlyFavorites(false); }}>查看全部图标</button></div>}
|
||
</div>
|
||
</main>
|
||
|
||
<aside className={styles.inspector}>
|
||
<div className={styles.inspectorHeader}><div><span>SELECTED ICON</span><strong>图标详情</strong></div><button className={favorites.includes(selected.id) ? styles.inspectorFavorite : ""} onClick={() => toggleFavorite(selected.id)}>{favorites.includes(selected.id) ? "★" : "☆"}</button></div>
|
||
<div className={`${styles.largePreview} ${styles[`preview_${background}`]}`} style={previewStyle}><span>{selected.symbol}</span><small>{size}px</small></div>
|
||
<div className={styles.iconMeta}><div><h2>{selected.cn}</h2><p>{selected.name}</p></div><button onClick={() => copy(selected.symbol, "图标字符")}>复制字符</button></div>
|
||
<dl className={styles.details}><div><dt>图标 ID</dt><dd>{selected.id}</dd></div><div><dt>分类</dt><dd>{categoryName}</dd></div><div><dt>Unicode</dt><dd>U+{unicodeValue(selected.symbol)}</dd></div></dl>
|
||
|
||
<section className={styles.appearance}><h3>预览样式</h3><label><span>大小</span><input type="range" min="14" max="64" value={size} onChange={(event) => setSize(Number(event.target.value))} /><b>{size}px</b></label><div className={styles.colorRow}><span>颜色</span>{["#27324a","#3d6fe8","#20a26a","#e45252","#8a5bd1"].map((value) => <button key={value} className={color === value ? styles.colorActive : ""} style={{background:value}} onClick={() => setColor(value)} aria-label={`颜色 ${value}`} />)}<input type="color" value={color} onChange={(event) => setColor(event.target.value)} aria-label="自定义颜色" /></div><div className={styles.backgroundSwitch}>{(["light","soft","dark"] as PreviewBackground[]).map((value) => <button key={value} className={background === value ? styles.backgroundActive : ""} onClick={() => setBackground(value)}>{value === "light" ? "白色" : value === "soft" ? "浅色" : "深色"}</button>)}</div></section>
|
||
|
||
<section className={styles.codeSection}>
|
||
<h3>使用代码</h3>
|
||
<div className={styles.codeTabs}>{(["character","html","react","css"] as CodeMode[]).map((mode) => <button key={mode} className={codeMode === mode ? styles.codeTabActive : ""} onClick={() => setCodeMode(mode)}>{mode === "character" ? "字符" : mode.toUpperCase()}</button>)}</div>
|
||
<pre><code>{code}</code></pre>
|
||
<button className={styles.copyCode} onClick={() => copy(code, `${codeMode.toUpperCase()} 代码`)}>▣ 复制代码</button>
|
||
</section>
|
||
<p className={styles.compatibility}><span>✓</span><strong>浏览器原生支持</strong><small>建议使用 Segoe UI Symbol、Arial Unicode MS 或系统无衬线字体。</small></p>
|
||
</aside>
|
||
</div>
|
||
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function categorySymbol(id: string) {
|
||
return ({ all: "✣", navigation: "⌖", actions: "✎", status: "◉", media: "▶", files: "▤", business: "▦", communication: "✉", identity: "⚥", biology: "⚛", breeding: "♂♀", pets: "🐾︎" } as Record<string,string>)[id] ?? "◇";
|
||
}
|
||
|
||
function unicodeValue(symbol: string) {
|
||
return Array.from(symbol).map((char) => char.codePointAt(0)?.toString(16).toUpperCase().padStart(4, "0")).join(" ");
|
||
}
|
||
|
||
function cssEscaped(symbol: string) {
|
||
return Array.from(symbol).map((char) => `\\${char.codePointAt(0)?.toString(16).toUpperCase()} `).join("");
|
||
}
|
||
|
||
function getIconCode(icon: IconItem, mode: CodeMode, size: number, color: string) {
|
||
if (mode === "character") return icon.symbol;
|
||
if (mode === "html") return `<span class="web-icon" aria-hidden="true">${icon.symbol}</span>`;
|
||
if (mode === "react") return `<span className="web-icon" aria-hidden>${icon.symbol}</span>`;
|
||
return `.web-icon::before {\n content: "${cssEscaped(icon.symbol)}";\n color: ${color};\n font: ${size}px/1 "Segoe UI Symbol", sans-serif;\n}`;
|
||
}
|