@code2rich/jpage 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +68 -0
- package/.dockerignore +8 -0
- package/.env.example +56 -0
- package/.github/workflows/ci.yml +43 -0
- package/CLAUDE.md +280 -0
- package/Dockerfile +44 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/README_EN.md +399 -0
- package/bin/args.js +64 -0
- package/bin/client.js +93 -0
- package/bin/commands/_shared.js +54 -0
- package/bin/commands/cat.js +23 -0
- package/bin/commands/ls.js +44 -0
- package/bin/commands/mv.js +20 -0
- package/bin/commands/rm.js +22 -0
- package/bin/commands/skills.js +70 -0
- package/bin/commands/star.js +23 -0
- package/bin/commands/tags.js +97 -0
- package/bin/commands/upload.js +84 -0
- package/bin/commands/url.js +25 -0
- package/bin/commands/whoami.js +29 -0
- package/bin/config.js +85 -0
- package/bin/jpage.js +168 -0
- package/build.js +112 -0
- package/docker-compose.yml +26 -0
- package/docs/api.md +438 -0
- package/docs/design/005-custom-modal.md +296 -0
- package/docs/design/013-file-version-history.md +324 -0
- package/docs/design/billing-system.md +600 -0
- package/docs/design/db-index-and-healthcheck.md +176 -0
- package/docs/design/loading-states.md +209 -0
- package/docs/virtual-hosting-feasibility.md +453 -0
- package/eslint.config.mjs +172 -0
- package/lib/auth-state.js +15 -0
- package/lib/categories.js +20 -0
- package/lib/crypto.js +85 -0
- package/lib/csp.js +66 -0
- package/lib/db.js +53 -0
- package/lib/dispatch.js +103 -0
- package/lib/fts.js +81 -0
- package/lib/middleware/auth.js +114 -0
- package/lib/middleware/files.js +42 -0
- package/lib/paths.js +9 -0
- package/lib/render-cache.js +48 -0
- package/lib/render.js +157 -0
- package/lib/templates.js +149 -0
- package/lib/util.js +66 -0
- package/lib/view-counts.js +59 -0
- package/lib/zip.js +192 -0
- package/logger.js +16 -0
- package/mailer.js +34 -0
- package/mcp/constants.js +16 -0
- package/mcp/resources.js +74 -0
- package/mcp/server.js +43 -0
- package/mcp/tools-categories.js +56 -0
- package/mcp/tools-content-templates.js +59 -0
- package/mcp/tools-files.js +245 -0
- package/mcp/tools-tags.js +41 -0
- package/mcp/tools-versions.js +57 -0
- package/mcp/transport.js +183 -0
- package/mcp/util.js +63 -0
- package/mcp-server.js +20 -0
- package/migrations/001_init_schema.js +25 -0
- package/migrations/002_add_share_key.js +33 -0
- package/migrations/003_add_roles_and_tokens.js +28 -0
- package/migrations/004_add_version_history.js +32 -0
- package/migrations/005_tags_starred_categories.js +49 -0
- package/migrations/006_zip_bundle.js +17 -0
- package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
- package/migrations/008_add_fts5.js +6 -0
- package/migrations/009_add_link_visits.js +20 -0
- package/migrations/010_add_templates_system.js +34 -0
- package/migrations/011_content_templates.js +233 -0
- package/migrations/012_add_email_and_verification.js +35 -0
- package/migrations/013_add_token_encrypted.js +14 -0
- package/migrations.js +65 -0
- package/package.json +63 -0
- package/public/css/style.css +2915 -0
- package/public/index.html +855 -0
- package/public/js/api.js +22 -0
- package/public/js/app.js +94 -0
- package/public/js/components/dialog.js +106 -0
- package/public/js/components/toast.js +13 -0
- package/public/js/pages/content-templates.js +330 -0
- package/public/js/pages/home.js +1903 -0
- package/public/js/pages/landing.js +158 -0
- package/public/js/pages/login.js +175 -0
- package/public/js/pages/preview.js +713 -0
- package/public/js/theme.js +44 -0
- package/public/js/utils.js +67 -0
- package/routes/admin.js +136 -0
- package/routes/auth.js +365 -0
- package/routes/categories.js +90 -0
- package/routes/content-templates.js +215 -0
- package/routes/files/_shared.js +112 -0
- package/routes/files/associations.js +94 -0
- package/routes/files/crud.js +139 -0
- package/routes/files/detail-serve.js +178 -0
- package/routes/files/index.js +38 -0
- package/routes/files/list.js +200 -0
- package/routes/files/overwrite.js +114 -0
- package/routes/files/upload.js +204 -0
- package/routes/files/versions.js +166 -0
- package/routes/files.js +16 -0
- package/routes/skills.js +93 -0
- package/routes/tags.js +65 -0
- package/routes/tokens.js +110 -0
- package/routes/users.js +120 -0
- package/server.js +372 -0
- package/skills/jpage-content-template/SKILL.md +98 -0
- package/skills/jpage-upload/SKILL.md +247 -0
- package/skills-registry.js +135 -0
- package/templates/academic.html +41 -0
- package/templates/dark-pro.html +41 -0
- package/templates/default.html +56 -0
- package/templates/github.html +67 -0
- package/test/browser-harness.js +125 -0
- package/test/dispatch-bench.js +74 -0
- package/test/helpers/setup.js +45 -0
- package/test/integration/admin.test.js +108 -0
- package/test/integration/auth.test.js +93 -0
- package/test/integration/categories.test.js +103 -0
- package/test/integration/cli.test.js +310 -0
- package/test/integration/content-templates.test.js +147 -0
- package/test/integration/files-security.test.js +248 -0
- package/test/integration/files.test.js +139 -0
- package/test/integration/share.test.js +79 -0
- package/test/integration/skills.test.js +104 -0
- package/test/integration/tags.test.js +84 -0
- package/test/integration/tokens.test.js +89 -0
- package/test/integration/users.test.js +138 -0
- package/test/mcp-harness.js +152 -0
- package/test/perf-bench.js +108 -0
- package/test/perf-harness.js +198 -0
- package/test/run-server.sh +15 -0
- package/test/unit/cli-args.test.js +88 -0
- package/test/unit/cli-config.test.js +89 -0
- package/test/unit/crypto.test.js +100 -0
- package/test/unit/fts.test.js +52 -0
- package/test/unit/render-cache.test.js +76 -0
- package/test/unit/util.test.js +81 -0
- package/test/unit/zip.test.js +164 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
// 预览页:渲染、源码/编辑切换、版本历史、统计、模板选择
|
|
2
|
+
|
|
3
|
+
import { api, API_BASE } from '../api.js';
|
|
4
|
+
import { toast } from '../components/toast.js';
|
|
5
|
+
import { dialogModal } from '../components/dialog.js';
|
|
6
|
+
import { escapeHtml, formatSize, relativeTime, buildSkeletonCards } from '../utils.js';
|
|
7
|
+
import { state, navigate } from '../app.js';
|
|
8
|
+
import { closeTemplateSelect } from './home.js';
|
|
9
|
+
|
|
10
|
+
// ---------- Preview Header State ----------
|
|
11
|
+
const PREVIEW_HEADER_COLLAPSED_KEY = 'htmlwebsite_preview_header_collapsed';
|
|
12
|
+
|
|
13
|
+
function syncPreviewHeaderState(layout, expandFloatingBtn, toggleHeaderBtn) {
|
|
14
|
+
const collapsed = layout.classList.contains('preview-header-collapsed');
|
|
15
|
+
if (toggleHeaderBtn) {
|
|
16
|
+
toggleHeaderBtn.setAttribute('aria-expanded', String(!collapsed));
|
|
17
|
+
toggleHeaderBtn.setAttribute('aria-label', collapsed ? '展开顶栏' : '收起顶栏');
|
|
18
|
+
toggleHeaderBtn.title = collapsed ? '展开顶栏' : '收起顶栏';
|
|
19
|
+
}
|
|
20
|
+
if (expandFloatingBtn) {
|
|
21
|
+
expandFloatingBtn.tabIndex = collapsed ? 0 : -1;
|
|
22
|
+
expandFloatingBtn.setAttribute('aria-hidden', collapsed ? 'false' : 'true');
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
sessionStorage.setItem(PREVIEW_HEADER_COLLAPSED_KEY, collapsed ? '1' : '0');
|
|
26
|
+
} catch (_) {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------- Version History ----------
|
|
30
|
+
const _versionPanelState = { fileId: null, versions: null, currentVer: 0 };
|
|
31
|
+
|
|
32
|
+
function loadVersions(container, fileId) {
|
|
33
|
+
const body = container.querySelector('#version-panel-body');
|
|
34
|
+
if (!body) return;
|
|
35
|
+
body.innerHTML = '<div class="version-empty">加载中…</div>';
|
|
36
|
+
|
|
37
|
+
api(`/api/files/${fileId}/versions`).then(data => {
|
|
38
|
+
_versionPanelState.fileId = fileId;
|
|
39
|
+
_versionPanelState.versions = data.versions || [];
|
|
40
|
+
_versionPanelState.currentVer = (data.versions ? data.versions.length : 0) + 1;
|
|
41
|
+
renderVersionList(container, data);
|
|
42
|
+
|
|
43
|
+
const menu = container.querySelector('#menu-version-history');
|
|
44
|
+
if (menu) menu.textContent = `历史 v${_versionPanelState.currentVer}`;
|
|
45
|
+
}).catch(e => {
|
|
46
|
+
body.innerHTML = `<div class="version-empty">加载失败: ${escapeHtml(e.message)}</div>`;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderVersionList(container, data) {
|
|
51
|
+
const body = container.querySelector('#version-panel-body');
|
|
52
|
+
if (!body) return;
|
|
53
|
+
|
|
54
|
+
const versions = data.versions || [];
|
|
55
|
+
const currentSize = data.current ? formatSize(data.current.size) : '';
|
|
56
|
+
const currentTime = data.current ? relativeTime(data.current.updated_at) : '';
|
|
57
|
+
|
|
58
|
+
if (versions.length === 0) {
|
|
59
|
+
body.innerHTML = `
|
|
60
|
+
<div class="version-item version-item-current">
|
|
61
|
+
<div class="version-item-row">
|
|
62
|
+
<span class="version-item-dot"></span>
|
|
63
|
+
<span class="version-item-label">当前 (v1)</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="version-item-meta">${currentSize} · ${currentTime}</div>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="version-empty">仅有当前版本</div>
|
|
68
|
+
`;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let html = `
|
|
73
|
+
<div class="version-item version-item-current">
|
|
74
|
+
<div class="version-item-row">
|
|
75
|
+
<span class="version-item-dot"></span>
|
|
76
|
+
<span class="version-item-label">当前 (v${versions.length + 1})</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="version-item-meta">${currentSize} · ${currentTime}</div>
|
|
79
|
+
</div>
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
versions.forEach(v => {
|
|
83
|
+
const vSize = formatSize(v.size);
|
|
84
|
+
const vTime = relativeTime(v.created_at);
|
|
85
|
+
html += `
|
|
86
|
+
<div class="version-item" data-version="${v.version}">
|
|
87
|
+
<div class="version-item-row">
|
|
88
|
+
<span class="version-item-dot"></span>
|
|
89
|
+
<span class="version-item-label">v${v.version}</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="version-item-meta">${vSize} · ${vTime}</div>
|
|
92
|
+
<div class="version-item-actions">
|
|
93
|
+
<button type="button" class="btn btn-small version-view" data-version="${v.version}">查看</button>
|
|
94
|
+
<button type="button" class="btn btn-small version-restore" data-version="${v.version}">恢复</button>
|
|
95
|
+
<button type="button" class="btn btn-small btn-danger version-delete" data-version="${v.version}">删除</button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
`;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
body.innerHTML = html;
|
|
102
|
+
|
|
103
|
+
// bind events
|
|
104
|
+
body.querySelectorAll('.version-view').forEach(btn => {
|
|
105
|
+
btn.addEventListener('click', e => {
|
|
106
|
+
e.stopPropagation();
|
|
107
|
+
const ver = btn.dataset.version;
|
|
108
|
+
const iframe = container.querySelector('#preview-iframe');
|
|
109
|
+
if (iframe) iframe.src = API_BASE + `/api/files/${_versionPanelState.fileId}/versions/${ver}/render`;
|
|
110
|
+
const source = container.querySelector('#preview-source');
|
|
111
|
+
if (source) source.classList.remove('active');
|
|
112
|
+
const iframeEl = container.querySelector('#preview-iframe');
|
|
113
|
+
if (iframeEl) iframeEl.style.display = 'block';
|
|
114
|
+
container.querySelectorAll('.view-toggle .btn').forEach(b => {
|
|
115
|
+
b.classList.toggle('active', b.dataset.mode === 'render');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
body.querySelectorAll('.version-restore').forEach(btn => {
|
|
121
|
+
btn.addEventListener('click', async e => {
|
|
122
|
+
e.stopPropagation();
|
|
123
|
+
const ver = btn.dataset.version;
|
|
124
|
+
const ok = await dialogModal.confirm({
|
|
125
|
+
title: '恢复版本',
|
|
126
|
+
message: `确定要恢复到 <strong>v${ver}</strong> 吗?当前版本将被保存为历史记录。`,
|
|
127
|
+
confirmText: '恢复',
|
|
128
|
+
});
|
|
129
|
+
if (!ok) return;
|
|
130
|
+
try {
|
|
131
|
+
await api(`/api/files/${_versionPanelState.fileId}/versions/${ver}/restore`, { method: 'POST' });
|
|
132
|
+
toast(`已恢复到 v${ver}`);
|
|
133
|
+
loadVersions(container, _versionPanelState.fileId);
|
|
134
|
+
const iframe = container.querySelector('#preview-iframe');
|
|
135
|
+
if (iframe) iframe.src = API_BASE + `/api/files/${_versionPanelState.fileId}/render`;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
toast(e.message, 'error');
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
body.querySelectorAll('.version-delete').forEach(btn => {
|
|
143
|
+
btn.addEventListener('click', async e => {
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
const ver = btn.dataset.version;
|
|
146
|
+
const ok = await dialogModal.confirm({
|
|
147
|
+
title: '删除版本',
|
|
148
|
+
message: `确定要删除 <strong>v${ver}</strong> 吗?此操作不可撤销。`,
|
|
149
|
+
confirmText: '删除',
|
|
150
|
+
danger: true,
|
|
151
|
+
});
|
|
152
|
+
if (!ok) return;
|
|
153
|
+
try {
|
|
154
|
+
await api(`/api/files/${_versionPanelState.fileId}/versions/${ver}`, { method: 'DELETE' });
|
|
155
|
+
toast(`已删除 v${ver}`);
|
|
156
|
+
loadVersions(container, _versionPanelState.fileId);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
toast(e.message, 'error');
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function openVersionPanel(container) {
|
|
165
|
+
const panel = container.querySelector('#version-panel');
|
|
166
|
+
if (!panel) return;
|
|
167
|
+
panel.hidden = false;
|
|
168
|
+
// trigger reflow then add class for animation
|
|
169
|
+
panel.offsetHeight;
|
|
170
|
+
panel.classList.add('open');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function closeVersionPanel(container) {
|
|
174
|
+
const panel = container.querySelector('#version-panel');
|
|
175
|
+
if (!panel) return;
|
|
176
|
+
panel.classList.remove('open');
|
|
177
|
+
setTimeout(() => { panel.hidden = true; }, 260);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function setupVersionUpload(container, fileId) {
|
|
181
|
+
const input = container.querySelector('#version-file-input');
|
|
182
|
+
if (!input) return;
|
|
183
|
+
|
|
184
|
+
input.addEventListener('change', () => {
|
|
185
|
+
if (!input.files.length) return;
|
|
186
|
+
const file = input.files[0];
|
|
187
|
+
const allowed = ['.html', '.htm', '.md', '.markdown'];
|
|
188
|
+
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
|
189
|
+
if (!allowed.includes(ext)) {
|
|
190
|
+
toast('仅支持 HTML 和 Markdown 文件', 'error');
|
|
191
|
+
input.value = '';
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const fd = new FormData();
|
|
196
|
+
fd.append('file', file);
|
|
197
|
+
|
|
198
|
+
const xhr = new XMLHttpRequest();
|
|
199
|
+
xhr.open('POST', API_BASE + `/api/files/${fileId}/overwrite`);
|
|
200
|
+
xhr.withCredentials = true;
|
|
201
|
+
|
|
202
|
+
xhr.onload = () => {
|
|
203
|
+
const data = JSON.parse(xhr.responseText || '{}');
|
|
204
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
205
|
+
const ver = data.version || '?';
|
|
206
|
+
toast(`已更新为第 ${ver} 版`);
|
|
207
|
+
const iframe = container.querySelector('#preview-iframe');
|
|
208
|
+
if (iframe) iframe.src = API_BASE + `/api/files/${fileId}/render`;
|
|
209
|
+
// refresh source view
|
|
210
|
+
api(`/api/files/${fileId}/content`).then(cdata => {
|
|
211
|
+
const code = container.querySelector('#source-code');
|
|
212
|
+
if (code) code.textContent = cdata.content;
|
|
213
|
+
}).catch(() => {});
|
|
214
|
+
loadVersions(container, fileId);
|
|
215
|
+
} else {
|
|
216
|
+
toast(data.error || `HTTP ${xhr.status}`, 'error');
|
|
217
|
+
}
|
|
218
|
+
input.value = '';
|
|
219
|
+
};
|
|
220
|
+
xhr.onerror = () => {
|
|
221
|
+
toast('上传失败,请检查网络', 'error');
|
|
222
|
+
input.value = '';
|
|
223
|
+
};
|
|
224
|
+
xhr.send(fd);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------- Stats Dialog ----------
|
|
229
|
+
async function openStatsDialog(fileId, container) {
|
|
230
|
+
closeVersionPanel(container);
|
|
231
|
+
try {
|
|
232
|
+
const stats = await api(`/api/files/${fileId}/stats`);
|
|
233
|
+
const html = buildStatsHtml(stats);
|
|
234
|
+
dialogModal.alert({ title: '访问统计', message: html, confirmText: '关闭' });
|
|
235
|
+
} catch (e) {
|
|
236
|
+
toast(e.message || '获取统计失败', 'error');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildStatsHtml(stats) {
|
|
241
|
+
const max7 = Math.max(1, ...stats.daily7.map(d => d.count));
|
|
242
|
+
const max30 = Math.max(1, ...stats.daily30.map(d => d.count));
|
|
243
|
+
const barChart = (data, maxVal) => {
|
|
244
|
+
if (!data.length) return '<div class="stats-empty">暂无数据</div>';
|
|
245
|
+
return '<div class="stats-chart">' + data.map(d => {
|
|
246
|
+
const pct = Math.max(2, Math.round(d.count / maxVal * 100));
|
|
247
|
+
const label = d.date.slice(5);
|
|
248
|
+
return `<div class="stats-bar-group"><div class="stats-bar" style="height:${pct}%"></div><div class="stats-bar-val">${d.count}</div><div class="stats-bar-label">${label}</div></div>`;
|
|
249
|
+
}).join('') + '</div>';
|
|
250
|
+
};
|
|
251
|
+
return `<div class="stats-summary"><span class="stats-total">总浏览量:<strong>${stats.viewCount}</strong></span></div>`
|
|
252
|
+
+ `<div class="stats-section"><h4>近 7 天</h4>${barChart(stats.daily7, max7)}</div>`
|
|
253
|
+
+ `<div class="stats-section"><h4>近 30 天</h4>${barChart(stats.daily30, max30)}</div>`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- 模板选择(预览页) ---
|
|
257
|
+
const TEMPLATE_VISUALS = {
|
|
258
|
+
'default': { bg: '#ffffff', text: '#57606a', heading: '#1f2328', code: '#f6f8fa', border: '#d0d7de' },
|
|
259
|
+
'github': { bg: '#ffffff', text: '#57606a', heading: '#1f2328', code: '#f6f8fa', border: '#d0d7de' },
|
|
260
|
+
'academic': { bg: '#fefcf3', text: '#3b3b3b', heading: '#1a1a1a', code: '#f5f1e8', border: '#d4c9a8' },
|
|
261
|
+
'dark-pro': { bg: '#1e1e2e', text: '#a6adc8', heading: '#f0f6fc', code: '#313244', border: '#45475a' },
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
async function openTemplateSelectForPreview(container, fileId, currentTemplateId) {
|
|
265
|
+
const modal = document.getElementById('template-select-modal');
|
|
266
|
+
if (!modal) return;
|
|
267
|
+
const list = document.getElementById('template-select-list');
|
|
268
|
+
list.innerHTML = '<div class="loading">加载中…</div>';
|
|
269
|
+
modal.hidden = false;
|
|
270
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
271
|
+
|
|
272
|
+
let allTemplates;
|
|
273
|
+
try {
|
|
274
|
+
const res = await authFetch('/api/templates');
|
|
275
|
+
const data = await res.json();
|
|
276
|
+
allTemplates = data.templates || [];
|
|
277
|
+
} catch (e) {
|
|
278
|
+
list.innerHTML = '<div class="empty-state">加载失败</div>';
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const isSelected = (t) => t.id === currentTemplateId || (!currentTemplateId && t.name === 'default');
|
|
283
|
+
|
|
284
|
+
list.innerHTML = allTemplates.map(t => {
|
|
285
|
+
const v = TEMPLATE_VISUALS[t.name] || TEMPLATE_VISUALS['default'];
|
|
286
|
+
const sel = isSelected(t);
|
|
287
|
+
return `<div class="tpl-card ${sel ? 'selected' : ''}" data-tpl-id="${t.id}">
|
|
288
|
+
<div class="tpl-preview" style="background:${v.bg};border-bottom:1px solid ${v.border}">
|
|
289
|
+
<div class="tpl-preview-heading" style="background:${v.heading}"></div>
|
|
290
|
+
<div class="tpl-preview-line" style="background:${v.text};opacity:.45"></div>
|
|
291
|
+
<div class="tpl-preview-line" style="background:${v.text};opacity:.3"></div>
|
|
292
|
+
<div class="tpl-preview-code" style="background:${v.code};border:1px solid ${v.border}"></div>
|
|
293
|
+
</div>
|
|
294
|
+
<div class="tpl-card-label">
|
|
295
|
+
<span>${t.description || t.name}</span>
|
|
296
|
+
<span class="tpl-card-check">✓</span>
|
|
297
|
+
</div>
|
|
298
|
+
</div>`;
|
|
299
|
+
}).join('');
|
|
300
|
+
|
|
301
|
+
list.querySelectorAll('.tpl-card').forEach(item => {
|
|
302
|
+
item.addEventListener('click', async () => {
|
|
303
|
+
const tplId = parseInt(item.dataset.tplId);
|
|
304
|
+
try {
|
|
305
|
+
await authFetch(`/api/files/${fileId}`, {
|
|
306
|
+
method: 'PUT',
|
|
307
|
+
headers: { 'Content-Type': 'application/json' },
|
|
308
|
+
body: JSON.stringify({ templateId: tplId })
|
|
309
|
+
});
|
|
310
|
+
const iframe = container.querySelector('#preview-iframe');
|
|
311
|
+
if (iframe) iframe.src = iframe.src;
|
|
312
|
+
} catch (e) { /* ignore */ }
|
|
313
|
+
closeTemplateSelect();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
document.getElementById('template-select-close').onclick = closeTemplateSelect;
|
|
318
|
+
document.getElementById('template-select-cancel').onclick = closeTemplateSelect;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------- Preview Page ----------
|
|
322
|
+
function renderPreview(container, hash) {
|
|
323
|
+
const id = hash.split('/').pop();
|
|
324
|
+
if (!id) return navigate('/');
|
|
325
|
+
|
|
326
|
+
const tmpl = document.getElementById('preview-template');
|
|
327
|
+
container.innerHTML = '';
|
|
328
|
+
container.appendChild(tmpl.content.cloneNode(true));
|
|
329
|
+
|
|
330
|
+
const layout = container.querySelector('#preview-layout-root');
|
|
331
|
+
const expandFloatingBtn = container.querySelector('#btn-preview-expand-floating');
|
|
332
|
+
const titleStrip = container.querySelector('#preview-title-expand');
|
|
333
|
+
const toggleHeaderBtn = container.querySelector('#btn-toggle-preview-header');
|
|
334
|
+
const iframe = container.querySelector('#preview-iframe');
|
|
335
|
+
const source = container.querySelector('#preview-source');
|
|
336
|
+
const code = container.querySelector('#source-code');
|
|
337
|
+
const sourceWrap = container.querySelector('#source-wrap');
|
|
338
|
+
const bundleTree = container.querySelector('#bundle-tree');
|
|
339
|
+
const bundleTreeBody = container.querySelector('#bundle-tree-body');
|
|
340
|
+
const bundleTreeCount = container.querySelector('#bundle-tree-count');
|
|
341
|
+
const spinner = container.querySelector('#preview-spinner');
|
|
342
|
+
const toggles = container.querySelectorAll('.view-toggle .btn');
|
|
343
|
+
const editBtn = container.querySelector('#btn-edit');
|
|
344
|
+
const editorContainer = container.querySelector('#editor-container');
|
|
345
|
+
const editorTextarea = container.querySelector('#editor-textarea');
|
|
346
|
+
const editorGutter = container.querySelector('#editor-gutter');
|
|
347
|
+
const editorStatusbar = container.querySelector('#editor-statusbar');
|
|
348
|
+
const editorSaveBtn = container.querySelector('#btn-editor-save');
|
|
349
|
+
const editorCancelBtn = container.querySelector('#btn-editor-cancel');
|
|
350
|
+
const menuDownload = container.querySelector('#menu-download');
|
|
351
|
+
const menuUploadVersion = container.querySelector('#menu-upload-version');
|
|
352
|
+
const menuVersionHistory = container.querySelector('#menu-version-history');
|
|
353
|
+
const moreDropdown = container.querySelector('#preview-more-dropdown');
|
|
354
|
+
const moreBtn = container.querySelector('#btn-preview-more');
|
|
355
|
+
let fileContent = '';
|
|
356
|
+
let editorOriginalContent = '';
|
|
357
|
+
let isBundleTree = false; // bundle 文件树是否已就绪
|
|
358
|
+
|
|
359
|
+
iframe.addEventListener('load', () => {
|
|
360
|
+
if (spinner) spinner.style.display = 'none';
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
let fileName;
|
|
364
|
+
|
|
365
|
+
function updateEditorLines() {
|
|
366
|
+
const lines = editorTextarea.value.split('\n').length;
|
|
367
|
+
let nums = '';
|
|
368
|
+
for (let i = 1; i <= lines; i++) nums += i + '\n';
|
|
369
|
+
editorGutter.textContent = nums;
|
|
370
|
+
editorStatusbar.textContent = `${lines} 行 · ${editorTextarea.value.length} 字符`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
editorTextarea.addEventListener('scroll', () => {
|
|
374
|
+
editorGutter.scrollTop = editorTextarea.scrollTop;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
function exitEditMode(mode) {
|
|
378
|
+
if (editorTextarea.value !== editorOriginalContent) {
|
|
379
|
+
return dialogModal.confirm({
|
|
380
|
+
title: '放弃编辑',
|
|
381
|
+
message: '内容已修改但未保存,确定要放弃吗?',
|
|
382
|
+
confirmText: '放弃',
|
|
383
|
+
}).then(ok => ok ? setViewMode(mode) : undefined);
|
|
384
|
+
}
|
|
385
|
+
setViewMode(mode);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function setViewMode(mode) {
|
|
389
|
+
const isEdit = mode === 'edit';
|
|
390
|
+
const showTree = isBundleTree && mode === 'source';
|
|
391
|
+
if (mode === 'render') {
|
|
392
|
+
iframe.style.display = 'block';
|
|
393
|
+
source.classList.remove('active');
|
|
394
|
+
} else if (mode === 'source') {
|
|
395
|
+
iframe.style.display = 'none';
|
|
396
|
+
source.classList.add('active');
|
|
397
|
+
} else if (isEdit) {
|
|
398
|
+
iframe.style.display = 'none';
|
|
399
|
+
source.classList.remove('active');
|
|
400
|
+
editorTextarea.value = fileContent;
|
|
401
|
+
editorOriginalContent = fileContent;
|
|
402
|
+
updateEditorLines();
|
|
403
|
+
setTimeout(() => editorTextarea.focus(), 0);
|
|
404
|
+
}
|
|
405
|
+
// Bundle 文件树仅 source 模式可见,并让 source-wrap 切换为双栏布局
|
|
406
|
+
if (bundleTree) bundleTree.hidden = !showTree;
|
|
407
|
+
if (sourceWrap) sourceWrap.classList.toggle('bundle-layout', showTree);
|
|
408
|
+
editorContainer.hidden = !isEdit;
|
|
409
|
+
editorSaveBtn.hidden = !isEdit;
|
|
410
|
+
editorCancelBtn.hidden = !isEdit;
|
|
411
|
+
if (moreDropdown) moreDropdown.style.display = isEdit ? 'none' : '';
|
|
412
|
+
toggles.forEach(b => {
|
|
413
|
+
b.classList.toggle('active', b.dataset.mode === mode);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
layout.classList.add('preview-header-collapsed');
|
|
419
|
+
} catch (_) {}
|
|
420
|
+
|
|
421
|
+
syncPreviewHeaderState(layout, expandFloatingBtn, toggleHeaderBtn);
|
|
422
|
+
|
|
423
|
+
// 点击标题:展开/收起整个顶栏(原「仅显示标题」工具栏折叠已移除)
|
|
424
|
+
titleStrip.addEventListener('click', () => {
|
|
425
|
+
layout.classList.toggle('preview-header-collapsed');
|
|
426
|
+
syncPreviewHeaderState(layout, expandFloatingBtn, toggleHeaderBtn);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
toggleHeaderBtn.addEventListener('click', e => {
|
|
430
|
+
e.stopPropagation();
|
|
431
|
+
layout.classList.toggle('preview-header-collapsed');
|
|
432
|
+
syncPreviewHeaderState(layout, expandFloatingBtn, toggleHeaderBtn);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expandFloatingBtn.addEventListener('click', () => {
|
|
436
|
+
layout.classList.remove('preview-header-collapsed');
|
|
437
|
+
syncPreviewHeaderState(layout, expandFloatingBtn, toggleHeaderBtn);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
container.querySelector('#btn-back').addEventListener('click', () => navigate('/'));
|
|
441
|
+
|
|
442
|
+
toggles.forEach(btn => {
|
|
443
|
+
btn.addEventListener('click', () => {
|
|
444
|
+
const target = btn.dataset.mode;
|
|
445
|
+
if (!editorContainer.hidden && target !== 'edit') {
|
|
446
|
+
exitEditMode(target);
|
|
447
|
+
} else {
|
|
448
|
+
setViewMode(target);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Editor: Tab key, Ctrl+S, Escape, scroll sync, line numbers
|
|
454
|
+
editorTextarea.addEventListener('keydown', (e) => {
|
|
455
|
+
if (e.key === 'Tab') {
|
|
456
|
+
e.preventDefault();
|
|
457
|
+
const start = editorTextarea.selectionStart;
|
|
458
|
+
const end = editorTextarea.selectionEnd;
|
|
459
|
+
editorTextarea.value = editorTextarea.value.substring(0, start) + ' ' + editorTextarea.value.substring(end);
|
|
460
|
+
editorTextarea.selectionStart = editorTextarea.selectionEnd = start + 2;
|
|
461
|
+
updateEditorLines();
|
|
462
|
+
}
|
|
463
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
464
|
+
e.preventDefault();
|
|
465
|
+
doEditorSave();
|
|
466
|
+
}
|
|
467
|
+
if (e.key === 'Escape') {
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
exitEditMode('render');
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
editorTextarea.addEventListener('input', updateEditorLines);
|
|
473
|
+
editorTextarea.addEventListener('scroll', () => {
|
|
474
|
+
editorGutter.scrollTop = editorTextarea.scrollTop;
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
async function doEditorSave() {
|
|
478
|
+
const content = editorTextarea.value;
|
|
479
|
+
editorSaveBtn.disabled = true;
|
|
480
|
+
editorSaveBtn.textContent = '保存中…';
|
|
481
|
+
try {
|
|
482
|
+
await api(`/api/files/${id}/overwrite-json`, {
|
|
483
|
+
method: 'POST',
|
|
484
|
+
body: { name: fileName, content }
|
|
485
|
+
});
|
|
486
|
+
toast('保存成功');
|
|
487
|
+
fileContent = content;
|
|
488
|
+
code.textContent = content;
|
|
489
|
+
if (spinner) spinner.style.display = 'flex';
|
|
490
|
+
iframe.src = API_BASE + `/api/files/${id}/render`;
|
|
491
|
+
loadVersions(container, id);
|
|
492
|
+
setViewMode('render');
|
|
493
|
+
} catch (e) {
|
|
494
|
+
toast(e.message || '保存失败', 'error');
|
|
495
|
+
} finally {
|
|
496
|
+
editorSaveBtn.disabled = false;
|
|
497
|
+
editorSaveBtn.textContent = '保存';
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
editorSaveBtn.addEventListener('click', doEditorSave);
|
|
502
|
+
editorCancelBtn.addEventListener('click', () => exitEditMode('render'));
|
|
503
|
+
|
|
504
|
+
// Download via menu
|
|
505
|
+
if (menuDownload) {
|
|
506
|
+
menuDownload.addEventListener('click', () => {
|
|
507
|
+
closeMoreDropdown();
|
|
508
|
+
const w = window.open(API_BASE + `/api/files/${id}/download`, '_blank');
|
|
509
|
+
if (w) w.opener = null;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Version history panel
|
|
514
|
+
const closeVersionBtn = container.querySelector('#btn-close-version-panel');
|
|
515
|
+
const versionPanel = container.querySelector('#version-panel');
|
|
516
|
+
|
|
517
|
+
setupVersionUpload(container, id);
|
|
518
|
+
|
|
519
|
+
function toggleVersionPanel() {
|
|
520
|
+
if (versionPanel && !versionPanel.hidden && versionPanel.classList.contains('open')) {
|
|
521
|
+
closeVersionPanel(container);
|
|
522
|
+
} else {
|
|
523
|
+
loadVersions(container, id);
|
|
524
|
+
openVersionPanel(container);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (menuVersionHistory) {
|
|
529
|
+
menuVersionHistory.addEventListener('click', () => { closeMoreDropdown(); toggleVersionPanel(); });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (closeVersionBtn) {
|
|
533
|
+
closeVersionBtn.addEventListener('click', () => closeVersionPanel(container));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Escape key closes version panel
|
|
537
|
+
document.addEventListener('keydown', function versionEscHandler(e) {
|
|
538
|
+
if (e.key === 'Escape' && versionPanel && !versionPanel.hidden && versionPanel.classList.contains('open')) {
|
|
539
|
+
closeVersionPanel(container);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// More dropdown: toggle + menu item delegation
|
|
544
|
+
function closeMoreDropdown() {
|
|
545
|
+
if (moreDropdown) {
|
|
546
|
+
moreDropdown.classList.remove('open');
|
|
547
|
+
moreBtn.setAttribute('aria-expanded', 'false');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (moreBtn) {
|
|
551
|
+
moreBtn.addEventListener('click', e => {
|
|
552
|
+
e.stopPropagation();
|
|
553
|
+
const isOpen = moreDropdown.classList.toggle('open');
|
|
554
|
+
moreBtn.setAttribute('aria-expanded', String(isOpen));
|
|
555
|
+
});
|
|
556
|
+
if (menuUploadVersion) menuUploadVersion.addEventListener('click', () => { closeMoreDropdown(); container.querySelector('#version-file-input')?.click(); });
|
|
557
|
+
if (menuVersionHistory && !menuVersionHistory._bound) { menuVersionHistory._bound = true; } // already bound above
|
|
558
|
+
// Close on outside click
|
|
559
|
+
document.addEventListener('click', function moreOutsideHandler(e) {
|
|
560
|
+
if (moreDropdown && !moreDropdown.contains(e.target)) closeMoreDropdown();
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------- Bundle 文件树 ----------
|
|
565
|
+
// 将扁平 entries [{path,size}] 转成嵌套树 {name, path, size?, children?}
|
|
566
|
+
function buildBundleTree(entries) {
|
|
567
|
+
const root = { name: '', path: '', children: [] };
|
|
568
|
+
const dirMap = new Map([[root.path, root]]);
|
|
569
|
+
// 先按 path 排序,保证目录先于其下文件出现,树稳定
|
|
570
|
+
const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path));
|
|
571
|
+
for (const e of sorted) {
|
|
572
|
+
const segs = e.path.split('/');
|
|
573
|
+
let curPath = '';
|
|
574
|
+
let parent = root;
|
|
575
|
+
for (let i = 0; i < segs.length; i++) {
|
|
576
|
+
const seg = segs[i];
|
|
577
|
+
curPath = curPath ? curPath + '/' + seg : seg;
|
|
578
|
+
const isLeaf = i === segs.length - 1;
|
|
579
|
+
if (isLeaf) {
|
|
580
|
+
parent.children.push({ name: seg, path: curPath, size: e.size });
|
|
581
|
+
} else {
|
|
582
|
+
let dir = dirMap.get(curPath);
|
|
583
|
+
if (!dir) {
|
|
584
|
+
dir = { name: seg, path: curPath, children: [] };
|
|
585
|
+
dirMap.set(curPath, dir);
|
|
586
|
+
parent.children.push(dir);
|
|
587
|
+
}
|
|
588
|
+
parent = dir;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return root;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 渲染单个树节点 -> 返回 DOM 元素
|
|
596
|
+
function renderBundleNode(node, onPick) {
|
|
597
|
+
const li = document.createElement('div');
|
|
598
|
+
li.className = 'bundle-node';
|
|
599
|
+
if (node.children) {
|
|
600
|
+
li.classList.add('bundle-dir', 'expanded');
|
|
601
|
+
const row = document.createElement('button');
|
|
602
|
+
row.type = 'button';
|
|
603
|
+
row.className = 'bundle-node-row bundle-dir-row';
|
|
604
|
+
row.innerHTML = `<svg class="bundle-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg><span class="bundle-name">${escapeHtml(node.name)}</span>`;
|
|
605
|
+
const childList = document.createElement('div');
|
|
606
|
+
childList.className = 'bundle-children';
|
|
607
|
+
node.children.forEach(c => childList.appendChild(renderBundleNode(c, onPick)));
|
|
608
|
+
row.addEventListener('click', () => {
|
|
609
|
+
const expanded = li.classList.toggle('expanded');
|
|
610
|
+
row.setAttribute('aria-expanded', String(expanded));
|
|
611
|
+
});
|
|
612
|
+
row.setAttribute('aria-expanded', 'true');
|
|
613
|
+
li.appendChild(row);
|
|
614
|
+
li.appendChild(childList);
|
|
615
|
+
} else {
|
|
616
|
+
li.classList.add('bundle-file');
|
|
617
|
+
const row = document.createElement('button');
|
|
618
|
+
row.type = 'button';
|
|
619
|
+
row.className = 'bundle-node-row bundle-file-row';
|
|
620
|
+
row.dataset.path = node.path;
|
|
621
|
+
row.innerHTML = `<span class="bundle-file-ic" aria-hidden="true">📄</span><span class="bundle-name">${escapeHtml(node.name)}</span>`;
|
|
622
|
+
row.addEventListener('click', () => onPick(node.path, row));
|
|
623
|
+
li.appendChild(row);
|
|
624
|
+
}
|
|
625
|
+
return li;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// 初始化 bundle 文件树
|
|
629
|
+
function setupBundleTree(entries, entryPath, truncated) {
|
|
630
|
+
if (!bundleTree || !bundleTreeBody) return;
|
|
631
|
+
isBundleTree = true;
|
|
632
|
+
const root = buildBundleTree(entries);
|
|
633
|
+
bundleTreeBody.innerHTML = '';
|
|
634
|
+
// 当前选中行
|
|
635
|
+
let activeRow = null;
|
|
636
|
+
function setActive(row) {
|
|
637
|
+
if (activeRow) activeRow.classList.remove('active');
|
|
638
|
+
if (row) { row.classList.add('active'); activeRow = row; }
|
|
639
|
+
}
|
|
640
|
+
// 点击文件:拉取内容显示到源码视图
|
|
641
|
+
async function pickFile(relPath, row) {
|
|
642
|
+
setActive(row);
|
|
643
|
+
code.textContent = '加载中…';
|
|
644
|
+
// 入口文件内容已在 /content 返回,避免重复请求
|
|
645
|
+
if (relPath === entryPath) {
|
|
646
|
+
code.textContent = fileContent;
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const resp = await fetch(API_BASE + `/api/files/${id}/asset/${encodeURI(relPath)}`, { credentials: 'include' });
|
|
651
|
+
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
652
|
+
code.textContent = await resp.text();
|
|
653
|
+
} catch (err) {
|
|
654
|
+
code.textContent = '读取失败:' + (err.message || err);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
root.children.forEach(c => bundleTreeBody.appendChild(renderBundleNode(c, pickFile)));
|
|
658
|
+
// 文件数提示
|
|
659
|
+
if (bundleTreeCount) {
|
|
660
|
+
const total = entries.length;
|
|
661
|
+
bundleTreeCount.textContent = truncated ? `${total}+ 个文件(已截断)` : `${total} 个文件`;
|
|
662
|
+
}
|
|
663
|
+
// 默认选中入口文件
|
|
664
|
+
const entryRow = bundleTreeBody.querySelector(`.bundle-file-row[data-path="${CSS.escape(entryPath || '')}"]`);
|
|
665
|
+
if (entryRow) {
|
|
666
|
+
setActive(entryRow);
|
|
667
|
+
code.textContent = fileContent;
|
|
668
|
+
} else {
|
|
669
|
+
code.textContent = fileContent;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
api(`/api/files/${id}/content`).then(data => {
|
|
674
|
+
fileName = data.original_name;
|
|
675
|
+
fileContent = data.content;
|
|
676
|
+
const lockPrefix = data.is_public ? '' : '<span class="file-lock" title="私有文件" aria-label="私有文件">🔒 </span>';
|
|
677
|
+
container.querySelector('#preview-title').innerHTML = lockPrefix + escapeHtml(data.original_name);
|
|
678
|
+
container.querySelector('#preview-heading').innerHTML = lockPrefix + escapeHtml(data.original_name);
|
|
679
|
+
expandFloatingBtn.title = `展开顶栏 · ${data.original_name}`;
|
|
680
|
+
expandFloatingBtn.setAttribute('aria-label', `展开顶栏 · ${data.original_name}`);
|
|
681
|
+
code.textContent = data.content;
|
|
682
|
+
// Bundle:初始化文件树,源码视图展示入口文件,可点击切换查看其它文件
|
|
683
|
+
if (data.is_bundle && Array.isArray(data.entries) && data.entries.length > 0) {
|
|
684
|
+
setupBundleTree(data.entries, data.entry_path, data.entries_truncated);
|
|
685
|
+
}
|
|
686
|
+
if (spinner) spinner.style.display = 'flex';
|
|
687
|
+
iframe.src = API_BASE + `/api/files/${id}/render`;
|
|
688
|
+
// 模板菜单项:仅 Markdown 文件且为所有者/admin 可见
|
|
689
|
+
const menuTemplate = container.querySelector('#menu-template');
|
|
690
|
+
if (menuTemplate && data.file_type === 'markdown' && state.currentUser && (state.currentUser.id == data.uploaded_by || state.currentUser.role === 'admin')) {
|
|
691
|
+
menuTemplate.hidden = false;
|
|
692
|
+
menuTemplate.addEventListener('click', () => {
|
|
693
|
+
closeMoreDropdown();
|
|
694
|
+
openTemplateSelectForPreview(container, id, data.template_id);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
// Show edit button only for owner or admin, and not for bundles
|
|
698
|
+
if (state.currentUser && !data.is_bundle && (state.currentUser.id == data.uploaded_by || state.currentUser.role === 'admin')) {
|
|
699
|
+
editBtn.hidden = false;
|
|
700
|
+
}
|
|
701
|
+
// 统计菜单项:仅文件所有者或 admin 可见
|
|
702
|
+
const menuStats = container.querySelector('#menu-stats');
|
|
703
|
+
if (menuStats && state.currentUser && (state.currentUser.role === 'admin' || state.currentUser.id == data.uploaded_by)) {
|
|
704
|
+
menuStats.hidden = false;
|
|
705
|
+
menuStats.addEventListener('click', () => { closeMoreDropdown(); openStatsDialog(id, container); });
|
|
706
|
+
}
|
|
707
|
+
}).catch(e => {
|
|
708
|
+
toast(e.message, 'error');
|
|
709
|
+
navigate('/');
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export { renderPreview };
|