fix:新增功能并优化

This commit is contained in:
Daniel
2026-04-02 11:15:21 +08:00
parent 0304805ce1
commit 8d69c0979c
13 changed files with 1283 additions and 214 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.DS_Store
npm-debug.log*
*.local
data/stats.sqlite

View File

@@ -3,80 +3,414 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shader 管理后台</title>
<title>管理后台 — Shadervault</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&display=swap"
rel="stylesheet"
/>
<style>
:root {
color-scheme: dark;
--bg-deep: #05060c;
--border: rgba(118, 138, 215, 0.28);
--accent: #8fa8ff;
--accent-dim: rgba(143, 168, 255, 0.14);
--text: #eef1fb;
--text-secondary: #a4adc8;
--text-muted: #6f7a96;
--radius-md: 14px;
--radius-lg: 18px;
--font-ui: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--font-display: "Archivo", var(--font-ui);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #0b0f18;
color: #e8ecff;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
min-height: 100vh;
background-color: var(--bg-deep);
background-image:
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(80, 100, 200, 0.18), transparent 50%),
radial-gradient(ellipse 60% 40% at 100% 50%, rgba(50, 70, 140, 0.08), transparent 45%);
color: var(--text);
font-family: var(--font-ui);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 980px;
max-width: 920px;
margin: 0 auto;
padding: 20px;
padding: 28px 20px 48px;
}
.page-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
}
.page-head h1 {
margin: 0 0 6px;
font-family: var(--font-display);
font-size: 1.65rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.page-head p {
margin: 0;
font-size: 0.875rem;
color: var(--text-muted);
max-width: 42ch;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
font-size: 0.8125rem;
font-weight: 600;
text-decoration: none;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.back-link svg {
width: 16px;
height: 16px;
opacity: 0.8;
}
.back-link:hover {
color: var(--text);
border-color: rgba(148, 170, 255, 0.45);
background: var(--accent-dim);
}
.back-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.card {
border: 1px solid rgba(126, 165, 255, 0.35);
background: #111726;
border-radius: 12px;
padding: 14px;
border: 1px solid var(--border);
background: linear-gradient(165deg, rgba(20, 24, 42, 0.75) 0%, rgba(10, 12, 22, 0.92) 100%);
border-radius: var(--radius-lg);
padding: 20px 22px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
.card h2 {
margin: 0 0 16px;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.02em;
}
.field-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
gap: 16px;
}
input, textarea, button {
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
border-radius: 8px;
}
input, textarea {
width: 100%;
padding: 8px;
}
textarea {
.field-full {
grid-column: 1 / -1;
min-height: 240px;
}
input,
textarea {
width: 100%;
padding: 11px 14px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.25);
color: var(--text);
font-family: inherit;
font-size: 0.9375rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input::placeholder,
textarea::placeholder {
color: var(--text-muted);
}
input:hover,
textarea:hover {
border-color: rgba(148, 170, 255, 0.35);
}
input:focus,
textarea:focus {
outline: none;
border-color: rgba(148, 170, 255, 0.55);
box-shadow: 0 0 0 3px var(--accent-dim);
}
textarea {
min-height: 260px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 0.8125rem;
line-height: 1.5;
}
button {
padding: 8px 12px;
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.btn {
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
padding: 10px 20px;
border-radius: 12px;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
}
.list { margin-top: 12px; }
.item {
.btn:active {
transform: scale(0.98);
}
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn-primary {
background: linear-gradient(180deg, rgba(120, 140, 220, 0.45) 0%, rgba(80, 100, 180, 0.35) 100%);
border-color: rgba(148, 170, 255, 0.45);
color: var(--text);
}
.btn-primary:hover {
background: linear-gradient(180deg, rgba(130, 150, 230, 0.55) 0%, rgba(90, 110, 190, 0.42) 100%);
border-color: rgba(180, 195, 255, 0.55);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.06);
border-color: var(--border);
color: var(--text-secondary);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text);
border-color: rgba(148, 170, 255, 0.35);
}
.list {
margin-top: 20px;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 10px 4px;
margin-bottom: 12px;
padding: 0 2px;
}
.list-header h2 {
margin: 0;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
}
.list-count {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.item-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
}
.btn-dl {
padding: 8px 14px;
font-size: 0.75rem;
text-decoration: none;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-secondary);
font-weight: 600;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.btn-dl:hover {
color: var(--text);
border-color: rgba(148, 170, 255, 0.4);
background: var(--accent-dim);
}
.btn-dl:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.list .item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.2);
margin-bottom: 8px;
transition: border-color 0.2s ease, background 0.2s ease;
}
.list .item:hover {
border-color: rgba(148, 170, 255, 0.22);
background: rgba(255, 255, 255, 0.03);
}
.list .item:last-child {
margin-bottom: 0;
}
.item-main strong {
display: block;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.item-meta {
font-size: 0.75rem;
color: var(--text-muted);
font-family: ui-monospace, monospace;
word-break: break-all;
}
.btn-danger {
flex-shrink: 0;
padding: 8px 14px;
font-size: 0.75rem;
background: rgba(220, 80, 90, 0.12);
border-color: rgba(255, 120, 130, 0.35);
color: #ffb4b8;
}
.btn-danger:hover {
background: rgba(220, 80, 90, 0.22);
border-color: rgba(255, 140, 150, 0.5);
color: #ffe0e2;
}
.empty-state {
text-align: center;
padding: 40px 24px;
border-radius: var(--radius-md);
border: 1px dashed var(--border);
color: var(--text-muted);
font-size: 0.875rem;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
.list .item {
flex-direction: column;
align-items: stretch;
}
.item-actions {
justify-content: flex-end;
}
}
.item:first-child { border-top: 0; }
</style>
</head>
<body>
<div class="wrap">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h2 style="margin:0;">Shader 管理后台</h2>
<a href="./index.html" style="color:#a9c5ff;">返回展示页</a>
</div>
<div class="card">
<header class="page-head">
<div>
<h1>Shader 管理</h1>
<p>保存后同步展示页并生成缩略图。</p>
</div>
<a class="back-link" href="./index.html">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
返回展示页
</a>
</header>
<section class="card" aria-labelledby="editor-heading">
<h2 id="editor-heading">新建 / 编辑</h2>
<div class="grid">
<input id="name-input" placeholder="名称" />
<input id="author-input" placeholder="作者(可选)" />
<textarea id="code-input" placeholder="粘贴 GLSL必须包含 mainImage"></textarea>
<div>
<label class="field-label" for="name-input">名称</label>
<input id="name-input" name="name" placeholder="例如 Aurora Noise" autocomplete="off" />
</div>
<div>
<label class="field-label" for="author-input">作者</label>
<input id="author-input" name="author" placeholder="可选" autocomplete="off" />
</div>
<div class="field-full">
<label class="field-label" for="code-input">GLSL 代码</label>
<textarea id="code-input" name="code" placeholder="粘贴 GLSL须包含 mainImage(out vec4 fragColor, in vec2 fragCoord)"></textarea>
</div>
</div>
<div style="display:flex; gap:8px; margin-top:10px;">
<button id="save-btn">保存</button>
<button id="example-btn">填入示例</button>
<div class="actions">
<button type="button" class="btn btn-primary" id="save-btn">保存到列表</button>
<button type="button" class="btn btn-secondary" id="example-btn">填入示例</button>
</div>
</div>
<div class="card list" id="list"></div>
</section>
<section class="list card" id="list-section" aria-labelledby="list-heading">
<div class="list-header">
<h2 id="list-heading">已保存</h2>
<span class="list-count" id="list-count" aria-live="polite"></span>
</div>
<div id="list" class="list-body"></div>
</section>
</div>
<script src="./thumb-renderer.js"></script>
<script src="./admin.js"></script>

View File

@@ -5,6 +5,15 @@ const codeInput = document.getElementById("code-input");
const saveBtn = document.getElementById("save-btn");
const exampleBtn = document.getElementById("example-btn");
const listEl = document.getElementById("list");
const listCountEl = document.getElementById("list-count");
function esc(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
const EXAMPLE = `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
@@ -29,19 +38,26 @@ async function fetchList() {
function renderList(items) {
listEl.innerHTML = "";
if (listCountEl) {
listCountEl.textContent = items.length ? `${items.length}` : "";
}
if (!items.length) {
listEl.innerHTML = "<div>暂无数据</div>";
listEl.innerHTML =
'<div class="empty-state">暂无着色器,请在上方创建并保存。</div>';
return;
}
items.forEach((item) => {
const row = document.createElement("div");
row.className = "item";
row.innerHTML = `
<div>
<strong>${item.name}</strong>
<div style="font-size:12px;color:#9fb2df;">${item.author || "unknown"} · ${item.id}</div>
<div class="item-main">
<strong>${esc(item.name)}</strong>
<div class="item-meta">${esc(item.author || "unknown")} · ${esc(item.id)}</div>
</div>
<div class="item-actions">
<a class="btn btn-dl" href="${API_BASE}/${encodeURIComponent(item.id)}/download" download>下载 .glsl</a>
<button type="button" class="btn btn-danger">删除</button>
</div>
<button>删除</button>
`;
row.querySelector("button").addEventListener("click", async () => {
await fetch(`${API_BASE}/${encodeURIComponent(item.id)}`, { method: "DELETE" });

View File

@@ -13,7 +13,27 @@ const backBtn = document.getElementById("back-btn");
const API_BASE = "/api/shaders";
/** 串行补全缺失缩略图(每步独立 WebGL用完即释放避免多卡片同时建上下文 */
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function getVisitorId() {
try {
let v = localStorage.getItem("sv_vid");
if (!v) {
v = crypto.randomUUID();
localStorage.setItem("sv_vid", v);
}
return v;
} catch {
return "anon";
}
}
const thumbBackfillQueue = [];
const thumbBackfillQueuedIds = new Set();
const thumbBackfillFailUntil = new Map();
@@ -85,11 +105,9 @@ function replaceCardStageWithThumb(id, url) {
stage.innerHTML = `<img class="card-thumb" src="${url}" alt="" loading="lazy" decoding="async" />`;
}
/** 全屏详情:限制像素量以便稳定 50+ FPS过高 DPR × 边长会显著拖慢片元着色器) */
const DETAIL_MAX_PIXEL_RATIO = 1.35;
const DETAIL_MAX_BUFFER_LONG_SIDE = 1680;
/** 卡片 hover 实时预览:单例 WebGL与缩略图同量级分辨率上限 */
const HOVER_MAX_PIXEL_RATIO = 1.25;
const HOVER_MAX_BUFFER_LONG_SIDE = 560;
@@ -135,7 +153,6 @@ function toPortableGlsl(code) {
.replace(/ANGLE_elem_ref\(([^)]+)\)/g, "$1")
.replace(/(\d+\.\d+|\d+)f\b/g, "$1");
// Keep only helper functions + mainImage block, drop struct/template leftovers.
const mi = s.indexOf("void mainImage");
if (mi >= 0) {
const head = s.slice(0, mi);
@@ -267,7 +284,6 @@ function createTexture(gl, width, height, data) {
}
function createDefaultChannelTextures(gl, noiseSize = 256) {
// iChannel0: audio-like lookup texture (matches many shadertoy audio assumptions)
const w0 = 512;
const h0 = 2;
const d0 = new Uint8Array(w0 * h0 * 4);
@@ -275,14 +291,12 @@ function createDefaultChannelTextures(gl, noiseSize = 256) {
const t = x / (w0 - 1);
const fft = Math.floor(255 * Math.pow(1.0 - t, 1.8));
const wave = Math.floor(128 + 127 * Math.sin(t * Math.PI * 10.0));
// row0: fft, row1: waveform
const i0 = (x + 0 * w0) * 4;
const i1 = (x + 1 * w0) * 4;
d0[i0] = fft; d0[i0 + 1] = fft; d0[i0 + 2] = fft; d0[i0 + 3] = 255;
d0[i1] = wave; d0[i1 + 1] = wave; d0[i1 + 2] = wave; d0[i1 + 3] = 255;
}
// iChannel1/2/3: tiling noise/grain-like textures (smaller in grid previews).
const size = noiseSize;
const makeNoise = (scaleA, scaleB) => {
const d = new Uint8Array(size * size * 4);
@@ -349,7 +363,6 @@ function computeHoverBufferSize(cssW, cssH) {
}
const previews = [];
/** Driven by scheduleRender(); detail + grid hover. */
let renderRafId = 0;
function scheduleRender() {
@@ -502,11 +515,28 @@ function startGridHover(previewState, card) {
}
function createPreviewCard(shader) {
const { id, name, author = "unknown", views = 0, likes = 0, code, thumbnailUrl } = shader;
const {
id,
name,
author = "unknown",
views = 0,
likes = 0,
code,
thumbnailUrl,
userLiked = false,
} = shader;
const card = document.createElement("article");
card.className = "card";
card.dataset.search = `${name} ${author}`.toLowerCase();
const authorRaw = String(author || "").trim();
const showAuthor = authorRaw && authorRaw.toLowerCase() !== "unknown";
const authorHtml = showAuthor
? `<span class="card-meta-author">${escHtml(authorRaw)}</span>`
: "";
const likedClass = userLiked ? " is-liked" : "";
const likedAria = userLiked ? "true" : "false";
const visualInner = thumbnailUrl
? `<img class="card-thumb" src="${thumbnailUrl}" alt="" loading="lazy" decoding="async" />`
: `<div class="card-thumb card-thumb--placeholder">暂无缩略图<br /><small>正在尝试自动生成…</small></div>`;
@@ -514,12 +544,21 @@ function createPreviewCard(shader) {
card.dataset.shaderId = id;
card.innerHTML = `
<div class="card-head">
<strong>${name}</strong>
<strong>${escHtml(name)}</strong>
</div>
<div class="card-stage">${visualInner}</div>
<div class="card-meta">
<span>by ${author}</span>
<span>views ${views} | likes ${likes}</span>
<div class="card-meta-left">${authorHtml}</div>
<div class="card-meta-stats">
<span class="stat-pill" title="浏览量">
<svg class="stat-ico" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<span class="js-views">${views}</span>
</span>
<button type="button" class="stat-pill stat-like${likedClass}" data-like-id="${id}" aria-label="点赞" aria-pressed="${likedAria}" ${userLiked ? "disabled" : ""}>
<svg class="stat-ico stat-ico-heart" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ${userLiked ? 'fill="currentColor" stroke="none"' : 'fill="none" stroke="currentColor" stroke-width="2"'} aria-hidden="true"><path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
<span class="js-likes">${likes}</span>
</button>
</div>
</div>
`;
@@ -528,9 +567,49 @@ function createPreviewCard(shader) {
name,
code,
card,
views,
likes,
userLiked,
};
card.addEventListener("click", () => openDetail(state));
const likeBtn = card.querySelector(".stat-like");
if (likeBtn) {
likeBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (likeBtn.classList.contains("is-liked") || likeBtn.disabled) return;
try {
const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}/like`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ visitorId: getVisitorId() }),
});
if (!res.ok) return;
const d = await res.json();
const likesEl = likeBtn.querySelector(".js-likes");
if (likesEl && d.likes != null) likesEl.textContent = String(d.likes);
state.likes = d.likes;
if (d.liked) {
likeBtn.classList.add("is-liked");
likeBtn.setAttribute("aria-pressed", "true");
likeBtn.disabled = true;
const hv = likeBtn.querySelector(".stat-ico-heart");
if (hv) {
hv.setAttribute("fill", "currentColor");
hv.removeAttribute("stroke");
hv.removeAttribute("stroke-width");
}
state.userLiked = true;
}
} catch (_) {
/* ignore */
}
});
}
card.addEventListener("click", (e) => {
if (e.target.closest(".stat-like")) return;
openDetail(state);
});
card.addEventListener("mouseenter", () => {
if (detailViewEl.classList.contains("active")) return;
@@ -578,6 +657,7 @@ function closeDetail() {
gl.deleteBuffer(vbo);
channelTextures.forEach((tex) => gl.deleteTexture(tex));
detailRuntime = null;
if (detailFpsEl) detailFpsEl.textContent = "—";
}
function openDetail(previewState) {
@@ -645,6 +725,17 @@ function openDetail(previewState) {
detailViewEl.classList.add("active");
detailViewEl.setAttribute("aria-hidden", "false");
scheduleRender();
void fetch(`${API_BASE}/${encodeURIComponent(previewState.id)}/view`, { method: "POST" })
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (!d || d.views == null) return;
const c = gridEl.querySelector(`[data-shader-id="${CSS.escape(previewState.id)}"]`);
const vEl = c && c.querySelector(".js-views");
if (vEl) vEl.textContent = String(d.views);
previewState.views = d.views;
})
.catch(() => {});
}
function applySearch() {
@@ -657,7 +748,10 @@ function applySearch() {
async function loadShaders() {
try {
const res = await fetch(`${API_BASE}?t=${Date.now()}`, { cache: "no-store" });
const res = await fetch(`${API_BASE}?t=${Date.now()}`, {
cache: "no-store",
headers: { "X-Visitor-Id": getVisitorId() },
});
if (!res.ok) throw new Error("加载失败");
const shaders = await res.json();
const signature = JSON.stringify(
@@ -685,7 +779,7 @@ function syncStats() {
statsEl.textContent = `预览数量: ${previews.length} | 全局时间: ${elapsed.toFixed(2)}s | 状态: ${
paused ? "已暂停" : "运行中"
}`;
shaderCountEl.textContent = `${previews.length} shaders`;
shaderCountEl.textContent = String(previews.length);
}
function renderAll(ts) {
@@ -699,7 +793,7 @@ function renderAll(ts) {
lastSec = Math.floor(elapsed);
fps = fpsCounter;
fpsCounter = 0;
globalFpsEl.textContent = `FPS: ${fps}`;
globalFpsEl.textContent = String(fps);
} else {
fpsCounter += 1;
}
@@ -785,7 +879,7 @@ function renderAll(ts) {
detailLastSec = Math.floor(elapsed);
detailFps = detailFpsCounter;
detailFpsCounter = 0;
detailFpsEl.textContent = `FPS: ${detailFps}`;
detailFpsEl.textContent = String(detailFps);
} else {
detailFpsCounter += 1;
}

View File

@@ -13,5 +13,5 @@ services:
- PORT=5180
- NODE_ENV=production
volumes:
- ./data:/app/data
- "${HOST_DATA_DIR:-./data}:/app/data"
restart: unless-stopped

View File

@@ -3,35 +3,77 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VFX 快速预览工具台</title>
<title>Shadervault — GLSL 展示</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&display=swap"
rel="stylesheet"
/>
<style>
:root {
color-scheme: dark;
--bg-deep: #05060c;
--bg-mid: #0a0d16;
--bg-elevated: rgba(12, 15, 26, 0.88);
--border: rgba(118, 138, 215, 0.28);
--border-strong: rgba(148, 170, 255, 0.45);
--accent: #8fa8ff;
--accent-dim: rgba(143, 168, 255, 0.14);
--accent-glow: rgba(100, 140, 255, 0.35);
--text: #eef1fb;
--text-secondary: #a4adc8;
--text-muted: #6f7a96;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.35);
--shadow-card: 0 8px 32px rgba(0, 0, 0, 0.42);
--shadow-card-hover: 0 16px 48px rgba(36, 56, 140, 0.22);
--font-ui: "DM Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-display: "Archivo", var(--font-ui);
--header-h: 64px;
}
* {
box-sizing: border-box;
}
html,
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.card {
transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
}
.card:hover {
transform: none !important;
}
}
body {
margin: 0;
width: 100%;
min-height: 100%;
background: radial-gradient(circle at 20% 0%, #13182a 0%, #090a0f 35%, #07080c 100%);
color: #e8e8eb;
font-family: "Space Grotesk", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"PingFang SC", "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-deep);
background-image:
radial-gradient(ellipse 120% 80% at 50% -30%, rgba(88, 110, 200, 0.22), transparent 52%),
radial-gradient(ellipse 70% 50% at 100% 0%, rgba(60, 90, 180, 0.12), transparent 45%),
radial-gradient(ellipse 50% 40% at 0% 20%, rgba(40, 60, 120, 0.1), transparent 40%);
color: var(--text);
font-family: var(--font-ui);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100vh;
padding-bottom: 48px;
}
.site-header {
@@ -41,139 +83,268 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid rgba(130, 148, 255, 0.25);
background: rgba(8, 10, 17, 0.82);
backdrop-filter: blur(8px);
gap: 16px;
min-height: var(--header-h);
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(10, 12, 22, 0.94) 0%, rgba(8, 10, 18, 0.88) 100%);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.brand-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.brand {
font-family: "Archivo", sans-serif;
font-size: 26px;
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 700;
letter-spacing: 0.4px;
color: #f7f8ff;
text-shadow: 0 0 18px rgba(92, 140, 255, 0.45);
letter-spacing: 0.03em;
color: var(--text);
line-height: 1.2;
text-shadow: 0 0 24px var(--accent-glow);
}
.brand-sub {
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-muted);
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
flex: 1;
justify-content: flex-end;
max-width: min(560px, 100%);
}
.search-field {
position: relative;
flex: 1;
min-width: 0;
max-width: 320px;
}
.search-field svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: var(--text-muted);
pointer-events: none;
}
.search {
width: 260px;
max-width: 42vw;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
width: 100%;
padding: 10px 14px 10px 40px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: #d6d9e8;
color: var(--text);
font-family: inherit;
font-size: 0.875rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
button {
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
color: #fff;
border-radius: 10px;
font-size: 13px;
.search::placeholder {
color: var(--text-muted);
}
button {
padding: 8px 12px;
cursor: pointer;
.search:hover {
border-color: rgba(148, 170, 255, 0.35);
background: rgba(255, 255, 255, 0.06);
}
button:hover {
background: rgba(255, 255, 255, 0.12);
}
.stats {
margin-top: 10px;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.2);
font-size: 12px;
color: #c3c5cd;
}
.view-hint {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
color: #8b92a8;
max-width: 720px;
.search:focus {
outline: none;
border-color: var(--border-strong);
box-shadow: 0 0 0 3px var(--accent-dim);
background: rgba(255, 255, 255, 0.07);
}
.main {
padding: 16px;
max-width: 1440px;
margin: 0 auto;
padding: 24px 20px 0;
}
.topbar {
.page-intro {
margin-bottom: 20px;
}
.page-title {
margin: 0 0 6px;
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.page-desc {
margin: 0;
font-size: 0.875rem;
color: var(--text-muted);
max-width: 52ch;
line-height: 1.55;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
gap: 14px;
margin-bottom: 16px;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.topbar strong {
font-family: "Archivo", sans-serif;
letter-spacing: 0.2px;
.chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
}
.chip strong {
color: var(--accent);
font-weight: 600;
margin-right: 4px;
}
button {
font-family: inherit;
cursor: pointer;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-weight: 600;
padding: 9px 16px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
}
button:hover {
background: rgba(255, 255, 255, 0.11);
border-color: rgba(148, 170, 255, 0.4);
}
button:active {
transform: scale(0.98);
}
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.stats {
margin-bottom: 20px;
padding: 12px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: linear-gradient(135deg, rgba(20, 24, 42, 0.6) 0%, rgba(12, 14, 24, 0.85) 100%);
font-size: 0.8125rem;
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(288px, 1fr));
gap: 16px;
}
.card {
content-visibility: auto;
contain-intrinsic-size: 300px 220px;
border: 1px solid rgba(104, 129, 255, 0.35);
border-radius: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
background: linear-gradient(180deg, #0f1220 0%, #0b0d15 100%);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
background: linear-gradient(165deg, rgba(22, 26, 44, 0.95) 0%, rgba(10, 12, 22, 0.98) 100%);
box-shadow: var(--shadow-card);
transition: transform 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease;
cursor: pointer;
}
.card:hover {
transform: translateY(-2px);
border-color: rgba(123, 178, 255, 0.7);
box-shadow: 0 14px 32px rgba(18, 46, 150, 0.25);
transform: translateY(-3px);
border-color: rgba(148, 170, 255, 0.5);
box-shadow: var(--shadow-card-hover);
}
.card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
color: #d6daea;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
gap: 10px;
padding: 12px 14px;
font-size: 0.8125rem;
color: var(--text-secondary);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.2);
}
.card-head strong {
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-stage {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
background: #000;
background: #020308;
overflow: hidden;
}
.card-stage::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
border-radius: 0;
}
.card-thumb {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
vertical-align: middle;
}
.card-hover-canvas {
@@ -187,30 +358,104 @@
.card-thumb--placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
padding: 12px;
padding: 16px;
text-align: center;
font-size: 12px;
line-height: 1.5;
color: #6a7088;
background: linear-gradient(145deg, #0a0c12 0%, #12151f 100%);
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-muted);
background: linear-gradient(155deg, #0c0e18 0%, #14182a 100%);
}
.card-thumb--placeholder small {
margin-top: 6px;
font-size: 0.7rem;
opacity: 0.85;
}
.card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px 10px;
font-size: 12px;
color: #9ea6c8;
gap: 10px;
padding: 10px 14px 12px;
font-size: 0.75rem;
color: var(--text-muted);
}
.card-meta-left {
min-width: 0;
flex: 1;
}
.card-meta-author {
color: var(--text-secondary);
font-weight: 500;
}
.card-meta-stats {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.stat-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.stat-like {
margin: 0;
padding: 5px 9px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
font: inherit;
color: inherit;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.stat-like:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(148, 170, 255, 0.35);
}
.stat-like:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.stat-like.is-liked {
color: #ff9aab;
border-color: rgba(255, 150, 170, 0.35);
background: rgba(255, 90, 120, 0.12);
cursor: default;
}
.stat-ico {
width: 15px;
height: 15px;
flex-shrink: 0;
opacity: 0.9;
}
.stat-like.is-liked .stat-ico-heart {
opacity: 1;
}
.error {
color: #ff8b8b;
font-size: 11px;
color: #ff9a9a;
font-size: 0.75rem;
white-space: pre-wrap;
}
@@ -219,8 +464,8 @@
inset: 0;
z-index: 20;
display: none;
grid-template-rows: 52px 1fr;
background: #05060a;
grid-template-rows: auto 1fr;
background: var(--bg-deep);
}
.detail-view.active {
@@ -231,20 +476,79 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(14, 16, 23, 0.95);
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(14, 16, 26, 0.98) 0%, rgba(8, 10, 18, 0.95) 100%);
backdrop-filter: blur(10px);
}
.detail-title-wrap {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.detail-view #detail-title {
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-fps-pill {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 2px 8px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
#detail-fps {
margin-left: 0 !important;
color: var(--accent) !important;
font-size: inherit !important;
}
#back-btn {
flex-shrink: 0;
border-radius: 999px;
padding: 10px 20px;
background: var(--accent-dim);
border-color: rgba(148, 170, 255, 0.35);
color: var(--text);
}
#back-btn:hover {
background: rgba(143, 168, 255, 0.22);
border-color: rgba(148, 170, 255, 0.55);
}
.detail-stage {
width: 100%;
height: calc(100vh - 52px);
min-height: 0;
height: calc(100vh - 72px);
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at 50% 30%, rgba(30, 40, 80, 0.25), transparent 55%), #000;
}
.detail-stage canvas {
width: 100%;
height: 100%;
display: block;
max-height: 100%;
background: #000;
}
@@ -254,8 +558,8 @@
z-index: 30;
display: none;
place-items: center;
background: rgba(4, 6, 10, 0.7);
backdrop-filter: blur(4px);
background: rgba(4, 6, 10, 0.75);
backdrop-filter: blur(6px);
}
.modal.active {
@@ -266,10 +570,10 @@
width: min(900px, 92vw);
max-height: 90vh;
overflow: auto;
border: 1px solid rgba(123, 178, 255, 0.5);
border-radius: 14px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
background: #0c0f1a;
padding: 14px;
padding: 16px;
}
.modal-grid {
@@ -281,10 +585,10 @@
.modal-grid input,
.modal-grid textarea {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.18);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: #fff;
border-radius: 8px;
border-radius: var(--radius-sm);
padding: 8px;
}
@@ -297,8 +601,8 @@
.table {
margin-top: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
@@ -314,42 +618,83 @@
.row-item:first-child {
border-top: 0;
}
@media (max-width: 640px) {
.site-header {
flex-wrap: wrap;
}
.header-right {
width: 100%;
max-width: none;
}
.search-field {
max-width: none;
}
.page-title {
font-size: 1.25rem;
}
}
</style>
</head>
<body>
<header class="site-header">
<div class="brand">Shadervault</div>
<div class="brand-block">
<span class="brand">Shadervault</span>
<span class="brand-sub">GLSL gallery</span>
</div>
<div class="header-right">
<input id="search-input" class="search" placeholder="Search shaders..." />
<label class="search-field">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
id="search-input"
class="search"
type="search"
placeholder="搜索名称或作者…"
autocomplete="off"
aria-label="搜索 shader"
/>
</label>
</div>
</header>
<div id="app">
<main class="main">
<div class="topbar">
<strong>Featured Shader Playlist</strong>
<div style="display: flex; align-items: center; gap: 8px">
<span id="shader-count">0 shaders</span>
<button id="pause-btn">全局暂停</button>
<button id="reset-btn">全局重置</button>
<span id="global-fps">FPS: --</span>
<div class="page-intro">
<h1 class="page-title">精选着色器</h1>
<p class="page-desc">悬停实时预览,点击进入全屏。</p>
</div>
<div class="toolbar">
<div class="toolbar-actions">
<span class="chip" id="shader-count-wrap"><strong id="shader-count">0</strong> 个着色器</span>
<button type="button" id="pause-btn">全局暂停</button>
<button type="button" id="reset-btn">重置时间</button>
<span class="chip" aria-live="polite"><strong>FPS</strong> <span id="global-fps"></span></span>
</div>
</div>
<div class="stats" id="stats">准备就绪</div>
<section id="preview-grid" class="preview-grid"></section>
<section id="preview-grid" class="preview-grid" aria-label="Shader 列表"></section>
</main>
</div>
<section id="detail-view" class="detail-view" aria-hidden="true">
<div class="detail-topbar">
<div>
<div class="detail-title-wrap">
<strong id="detail-title">特效详情</strong>
<span id="detail-fps" style="margin-left: 8px; color: #c8c9cf; font-size: 12px">FPS: --</span>
<span class="detail-fps-pill">实时 <span id="detail-fps">FPS —</span></span>
</div>
<button id="back-btn">返回列表</button>
<button type="button" id="back-btn">返回列表</button>
</div>
<div class="detail-stage">
<canvas id="detail-canvas"></canvas>
<canvas id="detail-canvas" aria-label="全屏 shader 预览"></canvas>
</div>
</section>

6
node_modules/.package-lock.json generated vendored
View File

@@ -759,6 +759,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sql.js": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.11.0.tgz",
"integrity": "sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

9
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"express": "^5.2.1"
"express": "^5.2.1",
"sql.js": "^1.11.0"
}
},
"node_modules/accepts": {
@@ -767,6 +768,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sql.js": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.11.0.tgz",
"integrity": "sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@@ -16,6 +16,7 @@
"license": "ISC",
"type": "commonjs",
"dependencies": {
"express": "^5.2.1"
"express": "^5.2.1",
"sql.js": "^1.11.0"
}
}

View File

@@ -2,6 +2,7 @@ const express = require("express");
const fs = require("fs/promises");
const path = require("path");
const crypto = require("crypto");
const statsDb = require("./stats-db");
const app = express();
const PORT = process.env.PORT || 5180;
@@ -37,6 +38,11 @@ async function thumbFileExists(id) {
}
}
function downloadFilenameBase(name) {
const s = String(name || "shader").trim() || "shader";
return s.replace(/[\\/:*?"<>|]/g, "_").slice(0, 120);
}
function decodePngBase64(s) {
if (!s || typeof s !== "string") return null;
const t = s.trim();
@@ -333,7 +339,6 @@ app.get("/api/shaders", async (_req, res) => {
try {
await ensureThumbnailsDir();
let shaders = await readDb();
// Runtime safeguard: always return normalized code for display layer.
shaders = shaders.map((item) => {
const raw = String(item.code || "");
const { error, normalized } = normalizeIncomingCode(raw);
@@ -343,13 +348,15 @@ app.get("/api/shaders", async (_req, res) => {
if (isAngleLike(raw)) next.sourceFormat = "angle-metal-auto-converted";
return next;
});
const visitorId = String(_req.get("x-visitor-id") || "").trim();
const out = await Promise.all(
shaders.map(async (item) => {
const has = await thumbFileExists(item.id);
const thumbnailUrl = has
? `/api/shaders/${encodeURIComponent(item.id)}/thumbnail`
: null;
return { ...item, thumbnailUrl };
const merged = statsDb.mergeItem({ ...item, thumbnailUrl }, visitorId);
return merged;
})
);
res.json(out);
@@ -358,6 +365,31 @@ app.get("/api/shaders", async (_req, res) => {
}
});
app.get("/api/shaders/:id/download", async (req, res) => {
try {
const shaders = await readDb();
const item = shaders.find((it) => it.id === req.params.id);
if (!item) {
return res.status(404).json({ error: "未找到该 shader" });
}
const raw =
item.sourceGlsl != null && String(item.sourceGlsl).length > 0
? String(item.sourceGlsl)
: String(item.code || "");
const base = downloadFilenameBase(item.name);
const fullName = `${base}.glsl`;
const asciiName = `${base.replace(/[^\x20-\x7e]/g, "_")}.glsl`;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fullName)}`
);
res.send(raw);
} catch {
res.status(500).json({ error: "导出失败" });
}
});
app.post("/api/shaders", async (req, res) => {
const { name, author = "unknown", code } = req.body || {};
if (!name || typeof name !== "string") {
@@ -370,11 +402,13 @@ app.post("/api/shaders", async (req, res) => {
try {
const shaders = await readDb();
const sourceGlsl = typeof code === "string" ? code : "";
const item = {
id: crypto.randomUUID(),
name: name.trim(),
author: String(author || "unknown").trim() || "unknown",
code: normalized,
sourceGlsl,
views: Math.floor(3000 + Math.random() * 22000),
likes: Math.floor(40 + Math.random() * 700),
createdAt: new Date().toISOString(),
@@ -382,12 +416,45 @@ app.post("/api/shaders", async (req, res) => {
};
shaders.unshift(item);
await writeDb(shaders);
res.status(201).json(item);
await statsDb.insertStats(item.id, item.views, item.likes);
res.status(201).json(statsDb.mergeItem(item, ""));
} catch {
res.status(500).json({ error: "保存失败" });
}
});
app.post("/api/shaders/:id/view", async (req, res) => {
try {
const shaders = await readDb();
const item = shaders.find((it) => it.id === req.params.id);
if (!item) {
return res.status(404).json({ error: "未找到该 shader" });
}
const row = await statsDb.incrementView(item.id, item);
res.json({ views: row.views, likes: row.likes });
} catch {
res.status(500).json({ error: "更新失败" });
}
});
app.post("/api/shaders/:id/like", async (req, res) => {
const visitorId = String((req.body && req.body.visitorId) || "").trim();
if (!visitorId) {
return res.status(400).json({ error: "visitorId 必填" });
}
try {
const shaders = await readDb();
const item = shaders.find((it) => it.id === req.params.id);
if (!item) {
return res.status(404).json({ error: "未找到该 shader" });
}
const out = await statsDb.tryLike(item.id, visitorId, item);
res.json(out);
} catch {
res.status(500).json({ error: "点赞失败" });
}
});
app.get("/api/shaders/:id/thumbnail", async (req, res) => {
try {
const p = thumbPath(req.params.id);
@@ -438,6 +505,7 @@ app.delete("/api/shaders/:id", async (req, res) => {
return res.status(404).json({ error: "未找到该 shader" });
}
await writeDb(next);
await statsDb.deleteStats(req.params.id);
await ensureThumbnailsDir();
try {
await fs.unlink(thumbPath(req.params.id));
@@ -450,11 +518,20 @@ app.delete("/api/shaders/:id", async (req, res) => {
}
});
ensureDb().then(async () => {
async function start() {
await ensureDb();
await statsDb.load();
const initialList = await readDb();
await statsDb.migrateIfEmpty(initialList);
try {
await autoNormalizeStoredShaders();
} catch (_) {}
app.listen(PORT, () => {
console.log(`Server running: http://localhost:${PORT}`);
});
}
start().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -4,6 +4,22 @@ set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${PROJECT_DIR}"
usage() {
echo "用法: $0 [端口]"
echo " 端口: 可选,映射到本机 HTTP容器内固定 5180。也可用环境变量 HOST_PORT。"
echo " 数据: 宿主机目录通过 HOST_DATA_DIR 指定,默认 <项目目录>/data与容器 /app/data 绑定,避免重建容器丢数据。"
echo "示例:"
echo " $0 # 交互输入端口,默认 5180"
echo " $0 8080 # 使用 8080"
echo " HOST_PORT=3000 $0"
echo " HOST_DATA_DIR=$HOME/vfxdemo-data $0 9000"
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if ! command -v docker >/dev/null 2>&1; then
echo "未检测到 docker请先安装 Docker Desktop 或 Docker Engine。"
exit 1
@@ -26,26 +42,49 @@ fi
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
find_free_port() {
local port="$1"
while lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; do
port=$((port + 1))
done
echo "${port}"
resolve_port() {
if [ -n "${1:-}" ]; then
printf '%s' "$1"
return
fi
if [ -n "${HOST_PORT:-}" ]; then
printf '%s' "${HOST_PORT}"
return
fi
if [ -t 0 ]; then
read -r -p "映射到本机的 HTTP 端口 [5180]: " _p
printf '%s' "${_p:-5180}"
return
fi
printf '%s' "5180"
}
HOST_PORT="$(find_free_port 5180)"
HOST_PORT="$(resolve_port "${1:-}")"
if ! [[ "$HOST_PORT" =~ ^[0-9]+$ ]] || [ "$HOST_PORT" -lt 1 ] || [ "$HOST_PORT" -gt 65535 ]; then
echo "无效端口: ${HOST_PORT}"
exit 1
fi
export HOST_PORT
echo "==> 使用国内镜像源构建并启动 VFXdemo"
echo " - Node 基础镜像: docker.m.daocloud.io"
echo " - npm registry: registry.npmmirror.com"
echo " - Host 端口: ${HOST_PORT}"
DATA_DEFAULT="${PROJECT_DIR}/data"
export HOST_DATA_DIR="${HOST_DATA_DIR:-${DATA_DEFAULT}}"
mkdir -p "${HOST_DATA_DIR}"
export HOST_DATA_DIR="$(cd "${HOST_DATA_DIR}" && pwd)"
if command -v lsof >/dev/null 2>&1; then
if lsof -nP -iTCP:"${HOST_PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
echo "警告: 本机端口 ${HOST_PORT} 已被占用compose 可能启动失败。请换端口: $0 <端口>"
fi
fi
echo "==> VFXdemo国内镜像源构建"
echo " 本机端口: ${HOST_PORT} -> 容器 5180"
echo " 数据目录: ${HOST_DATA_DIR} -> /app/data"
"${COMPOSE_CMD[@]}" build --pull
"${COMPOSE_CMD[@]}" up -d
echo
echo "启动完成: http://localhost:${HOST_PORT}"
echo "查看日志: ${COMPOSE_CMD[*]} logs -f"
echo "停止服务: ${COMPOSE_CMD[*]} down"
echo "访问: http://localhost:${HOST_PORT}"
echo "日志: ${COMPOSE_CMD[*]} logs -f"
echo "停止: ${COMPOSE_CMD[*]} down"

155
stats-db.js Normal file
View File

@@ -0,0 +1,155 @@
const fs = require("fs/promises");
const path = require("path");
const initSqlJs = require("sql.js");
const STATS_PATH = path.join(__dirname, "data", "stats.sqlite");
let db = null;
async function load() {
const SQL = await initSqlJs();
let buf;
try {
buf = await fs.readFile(STATS_PATH);
} catch {
buf = undefined;
}
db = buf ? new SQL.Database(buf) : new SQL.Database();
db.run(`
CREATE TABLE IF NOT EXISTS shader_stats (
id TEXT PRIMARY KEY NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
likes INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS shader_likes (
shader_id TEXT NOT NULL,
visitor_id TEXT NOT NULL,
PRIMARY KEY (shader_id, visitor_id)
);
`);
}
async function persist() {
const data = db.export();
await fs.writeFile(STATS_PATH, Buffer.from(data));
}
async function migrateIfEmpty(list) {
const r = db.exec("SELECT COUNT(*) FROM shader_stats");
const n = r.length && r[0].values.length ? r[0].values[0][0] : 0;
if (n > 0) return;
const stmt = db.prepare(
"INSERT INTO shader_stats (id, views, likes) VALUES (?, ?, ?)"
);
for (const item of list) {
stmt.run([
item.id,
Number(item.views) || 0,
Number(item.likes) || 0,
]);
}
stmt.free();
await persist();
}
function getStatsRow(id) {
const stmt = db.prepare("SELECT views, likes FROM shader_stats WHERE id = ?");
stmt.bind([id]);
if (!stmt.step()) {
stmt.free();
return null;
}
const o = stmt.getAsObject();
stmt.free();
return { views: o.views, likes: o.likes };
}
function mergeItem(item, visitorId) {
const row = getStatsRow(item.id);
const views = row ? row.views : Number(item.views) || 0;
const likes = row ? row.likes : Number(item.likes) || 0;
let userLiked = false;
if (visitorId) {
const s = db.prepare(
"SELECT 1 FROM shader_likes WHERE shader_id = ? AND visitor_id = ?"
);
s.bind([item.id, visitorId]);
userLiked = s.step();
s.free();
}
return { ...item, views, likes, userLiked };
}
async function incrementView(id, fallback) {
const row = getStatsRow(id);
if (!row) {
db.run("INSERT INTO shader_stats (id, views, likes) VALUES (?, ?, ?)", [
id,
(Number(fallback.views) || 0) + 1,
Number(fallback.likes) || 0,
]);
} else {
db.run("UPDATE shader_stats SET views = views + 1 WHERE id = ?", [id]);
}
await persist();
return getStatsRow(id);
}
async function tryLike(id, visitorId, fallback) {
const chk = db.prepare(
"SELECT 1 FROM shader_likes WHERE shader_id = ? AND visitor_id = ?"
);
chk.bind([id, visitorId]);
if (chk.step()) {
chk.free();
const r = getStatsRow(id);
return {
likes: r ? r.likes : Number(fallback.likes) || 0,
liked: false,
};
}
chk.free();
db.run("INSERT INTO shader_likes (shader_id, visitor_id) VALUES (?, ?)", [
id,
visitorId,
]);
const existing = getStatsRow(id);
if (!existing) {
db.run("INSERT INTO shader_stats (id, views, likes) VALUES (?, ?, ?)", [
id,
Number(fallback.views) || 0,
(Number(fallback.likes) || 0) + 1,
]);
} else {
db.run("UPDATE shader_stats SET likes = likes + 1 WHERE id = ?", [id]);
}
await persist();
return { likes: getStatsRow(id).likes, liked: true };
}
async function insertStats(id, views, likes) {
db.run("INSERT OR REPLACE INTO shader_stats (id, views, likes) VALUES (?, ?, ?)", [
id,
views,
likes,
]);
await persist();
}
async function deleteStats(id) {
db.run("DELETE FROM shader_likes WHERE shader_id = ?", [id]);
db.run("DELETE FROM shader_stats WHERE id = ?", [id]);
await persist();
}
module.exports = {
load,
migrateIfEmpty,
mergeItem,
incrementView,
tryLike,
insertStats,
deleteStats,
};

View File

@@ -1,12 +1,7 @@
/**
* One-off WebGL capture: try multiple iTime values, pick a non-degenerate frame (avoid flat black/white),
* export PNG, then loseContext.
*/
(function () {
const THUMB_W = 512;
const THUMB_H = 320;
/** 首帧常为过黑/过白/无对比时,在这些时刻采样并打分选帧 */
const THUMB_TIME_CANDIDATES = [
0, 0.1, 0.22, 0.45, 0.78, 1.15, 1.7, 2.25, 2.85, 3.45, 4.2, 5.1, 6.28, 7.5, 9, 11, 13,
];
@@ -276,11 +271,6 @@ void main() { mainImage(outColor, gl_FragCoord.xy); }`;
return varL * 85 + (1 - Math.abs(mean - 0.4) * 1.05);
}
/**
* @param {string} code — normalized GLSL (same as server returns)
* @param {string} name — shader title for fallback hash
* @returns {Promise<string>} data:image/png;base64,...
*/
window.captureShaderThumbnail = function captureShaderThumbnail(code, name) {
const canvas = document.createElement("canvas");
canvas.width = THUMB_W;