fix:新增功能并优化
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
*.local
|
||||
data/stats.sqlite
|
||||
418
admin.html
418
admin.html
@@ -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>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>Shader 管理</h1>
|
||||
<p>保存后同步展示页并生成缩略图。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<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 style="display:flex; gap:8px; margin-top:10px;">
|
||||
<button id="save-btn">保存</button>
|
||||
<button id="example-btn">填入示例</button>
|
||||
<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 class="card list" id="list"></div>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
26
admin.js
26
admin.js
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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" });
|
||||
|
||||
128
display.js
128
display.js
@@ -13,7 +13,27 @@ const backBtn = document.getElementById("back-btn");
|
||||
|
||||
const API_BASE = "/api/shaders";
|
||||
|
||||
/** 串行补全缺失缩略图(每步独立 WebGL,用完即释放),避免多卡片同时建上下文 */
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@ services:
|
||||
- PORT=5180
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- "${HOST_DATA_DIR:-./data}:/app/data"
|
||||
restart: unless-stopped
|
||||
|
||||
577
index.html
577
index.html
@@ -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
6
node_modules/.package-lock.json
generated
vendored
@@ -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
9
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1"
|
||||
"express": "^5.2.1",
|
||||
"sql.js": "^1.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
85
server.js
85
server.js
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
155
stats-db.js
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user