Files
meme-api/public/memes/index.html
2026-04-11 21:52:18 +08:00

674 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>表情包图库 · meme-api</title>
<style>
:root {
color-scheme: light dark;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
"PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.45;
--bg: Canvas;
--fg: CanvasText;
--card: color-mix(in oklab, Canvas 94%, CanvasText 6%);
--border: color-mix(in oklab, Canvas 82%, CanvasText 18%);
--accent: color-mix(in oklab, CanvasText 12%, Canvas 88%);
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
}
header {
padding: 1.25rem clamp(0.5rem, 2vw, 1.25rem);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: color-mix(in oklab, var(--bg) 88%, transparent);
backdrop-filter: blur(10px);
z-index: 2;
}
h1 {
font-size: 1.2rem;
margin: 0 0 0.35rem;
font-weight: 650;
}
.sub {
margin: 0;
font-size: 0.9rem;
opacity: 0.78;
}
.sub a {
color: inherit;
}
.cat-tabs {
display: flex;
flex-wrap: nowrap;
gap: 0.4rem;
overflow-x: auto;
overflow-y: hidden;
padding: 0.75rem 0 0;
margin: 0.15rem -0.35rem 0;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(to right, transparent, #000 0.5rem, #000 calc(100% - 0.5rem), transparent);
}
.cat-tabs::-webkit-scrollbar {
height: 4px;
}
.cat-tab {
flex: 0 0 auto;
font: inherit;
font-size: 0.86rem;
padding: 0.4rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: inherit;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s ease, border-color 0.12s ease;
-webkit-tap-highlight-color: transparent;
}
.cat-tab:hover {
background: color-mix(in oklab, var(--accent) 65%, transparent);
}
.cat-tab[aria-selected="true"] {
font-weight: 650;
border-color: color-mix(in oklab, CanvasText 28%, var(--border));
background: color-mix(in oklab, var(--accent) 100%, transparent);
}
.cat-tab:focus-visible {
outline: 2px solid color-mix(in oklab, CanvasText 40%, transparent);
outline-offset: 2px;
}
main {
padding: 1.25rem clamp(0.5rem, 2vw, 1rem) 3rem;
max-width: none;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
/* 手机:每行固定 4 列 */
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.4rem 0.35rem;
}
/* 电脑:每行最多 12 列,占满可用宽度 */
@media (min-width: 769px) {
.grid {
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 0.55rem 0.5rem;
}
}
.tile {
display: block;
width: 100%;
border: none;
margin: 0;
padding: 0;
border-radius: 0;
background: transparent;
cursor: pointer;
text-align: center;
transition: opacity 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.tile:hover {
opacity: 0.88;
}
.tile:focus-visible {
outline: 2px solid color-mix(in oklab, CanvasText 40%, transparent);
outline-offset: 3px;
}
.tile img {
display: block;
width: 100%;
aspect-ratio: 1;
height: auto;
object-fit: contain;
vertical-align: middle;
border-radius: 0;
background: transparent;
}
.tile .label {
margin-top: 0.2rem;
font-size: 0.68rem;
opacity: 0.55;
word-break: break-all;
line-height: 1.2;
}
.toast {
position: fixed;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%) translateY(120%);
opacity: 0;
padding: 0.6rem 1rem;
border-radius: 10px;
background: color-mix(in oklab, CanvasText 88%, Canvas 12%);
color: Canvas;
font-size: 0.9rem;
transition: transform 0.22s ease, opacity 0.22s ease;
z-index: 9;
pointer-events: none;
max-width: min(92vw, 420px);
text-align: center;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.error {
padding: 1rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--card);
}
.empty {
opacity: 0.8;
padding: 1rem 0;
}
.sheet-root {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: flex-end;
justify-content: center;
padding: env(safe-area-inset-bottom, 0) 0.75rem 0.75rem;
box-sizing: border-box;
}
.sheet-root[hidden] {
display: none !important;
}
.sheet-backdrop {
position: absolute;
inset: 0;
background: color-mix(in oklab, CanvasText 38%, transparent);
border: none;
padding: 0;
cursor: pointer;
}
.sheet-panel {
position: relative;
width: min(22rem, 100%);
max-height: min(85vh, 28rem);
overflow: auto;
border-radius: 14px 14px 0 0;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
box-shadow: 0 -8px 32px color-mix(in oklab, CanvasText 22%, transparent);
padding: 0.65rem 0;
margin-bottom: 0;
}
@media (min-width: 480px) {
.sheet-root {
align-items: center;
padding: 1rem;
}
.sheet-panel {
border-radius: 14px;
margin-bottom: 0;
}
}
.sheet-title {
margin: 0;
padding: 0.35rem 1rem 0.65rem;
font-size: 0.82rem;
font-weight: 600;
opacity: 0.85;
word-break: break-all;
border-bottom: 1px solid var(--border);
}
.sheet-btn {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: 0.95rem;
padding: 0.75rem 1rem;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
transition: background 0.12s ease;
-webkit-tap-highlight-color: transparent;
}
.sheet-btn:hover,
.sheet-btn:focus-visible {
background: color-mix(in oklab, var(--accent) 85%, transparent);
}
.sheet-btn:active {
opacity: 0.92;
}
.sheet-cancel {
margin-top: 0.25rem;
border-top: 1px solid var(--border);
text-align: center;
font-weight: 500;
opacity: 0.78;
}
.sheet-size-bar {
padding: 0.5rem 1rem 0.65rem;
border-bottom: 1px solid var(--border);
}
.sheet-size-label {
display: block;
font-size: 0.72rem;
opacity: 0.68;
margin-bottom: 0.45rem;
}
.sheet-size-btns {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.sheet-size-btn {
font: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 0.35rem 0.55rem;
min-width: 2.75rem;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.sheet-size-btn.is-active {
border-color: color-mix(in oklab, CanvasText 28%, var(--border));
background: color-mix(in oklab, var(--accent) 100%, transparent);
}
.sheet-size-btn:focus-visible {
outline: 2px solid color-mix(in oklab, CanvasText 40%, transparent);
outline-offset: 2px;
}
</style>
</head>
<body>
<header>
<h1>表情包图库</h1>
<p class="sub">
<a href="/">API</a>
·
<a href="/memes.json"><code>memes.json</code></a>
</p>
<nav class="cat-tabs" id="cat-tabs" aria-label="表情包分类"></nav>
</header>
<main id="app">
<p class="empty">加载中…</p>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<div id="sheet-root" class="sheet-root" hidden>
<button type="button" class="sheet-backdrop" id="sheet-backdrop" aria-label="关闭"></button>
<div class="sheet-panel" role="dialog" aria-modal="true" aria-labelledby="sheet-title">
<p id="sheet-title" class="sheet-title"></p>
<button type="button" class="sheet-btn" id="act-url">复制 url链接</button>
<button type="button" class="sheet-btn" id="act-md">复制 Markdown</button>
<div class="sheet-size-bar" id="sheet-size-bar" role="group" aria-label="HTML 宽度">
<span class="sheet-size-label">HTML 宽度</span>
<div class="sheet-size-btns">
<button type="button" class="sheet-size-btn" data-w="96" aria-pressed="false">96</button>
<button type="button" class="sheet-size-btn" data-w="128" aria-pressed="false">128</button>
<button type="button" class="sheet-size-btn" data-w="160" aria-pressed="false">160</button>
<button type="button" class="sheet-size-btn" data-w="200" aria-pressed="false">200</button>
<button type="button" class="sheet-size-btn" data-w="240" aria-pressed="false">240</button>
<button type="button" class="sheet-size-btn" data-w="280" aria-pressed="false">280</button>
<button type="button" class="sheet-size-btn" data-w="320" aria-pressed="false">320</button>
<button type="button" class="sheet-size-btn" data-w="400" aria-pressed="false">400</button>
<button type="button" class="sheet-size-btn" data-w="original" aria-pressed="false">原图</button>
</div>
</div>
<button type="button" class="sheet-btn" id="act-html">复制 HTML</button>
<button type="button" class="sheet-btn" id="act-download">下载</button>
<button type="button" class="sheet-btn sheet-cancel" id="act-close">取消</button>
</div>
</div>
<script>
(function () {
const app = document.getElementById("app");
const tabsEl = document.getElementById("cat-tabs");
const toastEl = document.getElementById("toast");
const sheetRoot = document.getElementById("sheet-root");
const sheetTitle = document.getElementById("sheet-title");
const sheetBackdrop = document.getElementById("sheet-backdrop");
/** @type {{ url: string; file: string; category: string } | null} */
var sheetCtx = null;
/** @type {number | null} null = 原图尺寸 */
var selectedWidthPx = 240;
var WIDTH_STORAGE = "meme_copy_img_width";
var VALID_HTML_WIDTHS = [96, 128, 160, 200, 240, 280, 320, 400];
function loadSavedWidth() {
try {
var v = localStorage.getItem(WIDTH_STORAGE);
if (v === "original") selectedWidthPx = null;
else {
var n = parseInt(v, 10);
selectedWidthPx =
VALID_HTML_WIDTHS.indexOf(n) >= 0 ? n : 240;
}
} catch (e) {
selectedWidthPx = 240;
}
}
function persistWidth() {
try {
localStorage.setItem(
WIDTH_STORAGE,
selectedWidthPx == null ? "original" : String(selectedWidthPx)
);
} catch (e) {}
}
function syncSizeBarUI() {
var bar = document.getElementById("sheet-size-bar");
if (!bar) return;
bar.querySelectorAll(".sheet-size-btn").forEach(function (btn) {
var w = btn.getAttribute("data-w");
var active =
(w === "original" && selectedWidthPx == null) ||
(w !== "original" && parseInt(w, 10) === selectedWidthPx);
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function wireSizeBar() {
document.querySelectorAll("#sheet-size-bar .sheet-size-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
var w = btn.getAttribute("data-w");
selectedWidthPx = w === "original" ? null : parseInt(w, 10);
persistWidth();
syncSizeBarUI();
});
});
}
loadSavedWidth();
wireSizeBar();
syncSizeBarUI();
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
async function copyText(text, toastMsg) {
try {
await navigator.clipboard.writeText(text);
showToast(toastMsg || "已复制到剪贴板");
} catch {
window.prompt("请手动复制:", text);
}
}
function escapeAttr(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
}
function markdownImage(url, alt) {
var a = String(alt || "表情").replace(/\]/g, "");
return "![" + a + "](" + url + ")";
}
function htmlEmbed(url, alt, widthPx) {
var parts =
'<img src="' +
escapeAttr(url) +
'" alt="' +
escapeAttr(alt || "") +
'" loading="lazy" draggable="false"';
if (widthPx != null) {
parts +=
' width="' +
widthPx +
'" style="max-width:' +
widthPx +
'px;width:100%;height:auto"';
}
return parts + " />";
}
function openSheet(url, file, category) {
sheetCtx = { url: url, file: file, category: category };
sheetTitle.textContent = (category ? category + " / " : "") + file;
syncSizeBarUI();
sheetRoot.hidden = false;
document.body.style.overflow = "hidden";
}
function closeSheet() {
sheetRoot.hidden = true;
document.body.style.overflow = "";
sheetCtx = null;
}
function wireSheetActions() {
sheetBackdrop.addEventListener("click", closeSheet);
document.getElementById("act-close").addEventListener("click", closeSheet);
document.getElementById("act-url").addEventListener("click", function () {
if (!sheetCtx) return;
copyText(sheetCtx.url, "已复制链接");
closeSheet();
});
document.getElementById("act-md").addEventListener("click", function () {
if (!sheetCtx) return;
var alt = sheetCtx.category + "/" + sheetCtx.file;
copyText(markdownImage(sheetCtx.url, alt), "已复制 Markdown");
closeSheet();
});
document.getElementById("act-html").addEventListener("click", function () {
if (!sheetCtx) return;
var alt = sheetCtx.category + "/" + sheetCtx.file;
var tip =
selectedWidthPx == null ? "已复制 HTML" : "已复制 HTML" + selectedWidthPx + "px";
copyText(htmlEmbed(sheetCtx.url, alt, selectedWidthPx), tip);
closeSheet();
});
document.getElementById("act-download").addEventListener("click", function () {
if (!sheetCtx) return;
var u = sheetCtx.url;
var name = sheetCtx.file;
closeSheet();
(async function () {
try {
var res = await fetch(u);
if (!res.ok) throw new Error("HTTP " + res.status);
var blob = await res.blob();
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = objectUrl;
a.download = name;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
showToast("已开始下载");
} catch (e) {
var link = document.createElement("a");
link.href = u;
link.download = name;
link.rel = "noopener";
link.target = "_blank";
document.body.appendChild(link);
link.click();
link.remove();
showToast("已尝试下载");
}
})();
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && !sheetRoot.hidden) closeSheet();
});
}
wireSheetActions();
function readHashCategory() {
var raw = location.hash.replace(/^#/, "");
if (!raw) return null;
try {
return decodeURIComponent(raw);
} catch (e) {
return raw;
}
}
function setHashCategory(id) {
var enc = encodeURIComponent(id);
if (location.hash !== "#" + enc) {
history.replaceState(null, "", "#" + enc);
}
}
function render(data) {
/** 可选 CDN 根;未设置时清单与 /meme 相对当前页面解析,支持子路径部署与本机 file:// 打开 */
var assetBase = data.baseUrl ? String(data.baseUrl).replace(/\/?$/, "") : "";
function memeAssetUrl(catId, fileName) {
var rel =
"../meme/" +
encodeURIComponent(catId) +
"/" +
encodeURIComponent(fileName);
return assetBase
? assetBase + "/meme/" + encodeURIComponent(catId) + "/" + encodeURIComponent(fileName)
: new URL(rel, location.href).href;
}
var categories = data.categories || [];
if (!categories.length) {
tabsEl.innerHTML = "";
app.innerHTML =
'<div class="empty">暂无数据。构建并部署后可在此查看。</div>';
return;
}
var selectedId = readHashCategory();
var validIds = {};
for (var i = 0; i < categories.length; i++) {
validIds[categories[i].id] = true;
}
if (!selectedId || !validIds[selectedId]) {
selectedId = categories[0].id;
setHashCategory(selectedId);
}
function findCategory(id) {
for (var j = 0; j < categories.length; j++) {
if (categories[j].id === id) return categories[j];
}
return categories[0];
}
function renderTabs() {
tabsEl.innerHTML = "";
for (var k = 0; k < categories.length; k++) {
var cat = categories[k];
var btn = document.createElement("button");
btn.type = "button";
btn.className = "cat-tab";
btn.setAttribute("role", "tab");
btn.setAttribute("aria-selected", cat.id === selectedId ? "true" : "false");
btn.textContent = (cat.name || cat.id) + " · " + (cat.items || []).length;
btn.addEventListener(
"click",
(function (id) {
return function () {
if (selectedId === id) return;
selectedId = id;
setHashCategory(id);
renderTabs();
renderGrid();
};
})(cat.id)
);
tabsEl.appendChild(btn);
}
var selectedBtn = tabsEl.querySelector('[aria-selected="true"]');
if (selectedBtn) {
selectedBtn.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
}
}
function renderGrid() {
var cat = findCategory(selectedId);
var items = cat.items || [];
app.innerHTML = "";
var grid = document.createElement("div");
grid.className = "grid";
for (let n = 0; n < items.length; n++) {
const it = items[n];
const url = it.url || memeAssetUrl(cat.id, it.file);
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.title = it.file;
const img = document.createElement("img");
img.loading = "lazy";
img.alt = it.file;
img.src = url;
tile.appendChild(img);
const label = document.createElement("div");
label.className = "label";
label.textContent = it.file;
tile.appendChild(label);
tile.addEventListener("click", function () {
openSheet(url, it.file, cat.name || cat.id);
});
grid.appendChild(tile);
}
app.appendChild(grid);
}
renderTabs();
renderGrid();
window.addEventListener("hashchange", function () {
var hid = readHashCategory();
if (hid && validIds[hid] && hid !== selectedId) {
selectedId = hid;
renderTabs();
renderGrid();
}
});
}
fetch(
(function () {
try {
return new URL("../memes.json", location.href).href;
} catch (e) {
return "/memes.json";
}
})()
)
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(render)
.catch(function (e) {
app.innerHTML =
'<div class="error">加载失败:<code>' +
String(e.message || e) +
"</code></div>";
});
})();
</script>
</body>
</html>