@code2rich/jpage 1.5.0 → 1.5.2

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.
Files changed (44) hide show
  1. package/.github/workflows/ci.yml +3 -1
  2. package/.github/workflows/release.yml +87 -0
  3. package/CLAUDE.md +3 -1
  4. package/README.md +26 -2
  5. package/bin/commands/ls.js +6 -3
  6. package/bin/commands/update.js +74 -0
  7. package/bin/jpage.js +7 -2
  8. package/docs/RELEASING.md +209 -0
  9. package/docs/skill-integration-design.md +384 -0
  10. package/eslint.config.mjs +2 -0
  11. package/lib/csp.js +8 -2
  12. package/lib/render.js +9 -2
  13. package/lib/templates.js +1 -1
  14. package/mcp/tools-files.js +5 -1
  15. package/package.json +4 -4
  16. package/public/css/style.css +128 -1
  17. package/public/index.html +51 -3
  18. package/public/js/app.js +8 -6
  19. package/public/js/pages/content-templates.js +1 -1
  20. package/public/js/pages/home.js +218 -9
  21. package/public/js/pages/landing.js +1 -1
  22. package/public/js/pages/preview.js +1 -1
  23. package/public/js/utils.js +15 -7
  24. package/routes/skills.js +77 -3
  25. package/server.js +10 -3
  26. package/skills/jpage-presentation/INSTALL.md +50 -0
  27. package/skills/jpage-presentation/README.md +71 -0
  28. package/skills/jpage-presentation/SKILL.md +226 -0
  29. package/skills/jpage-presentation/assets/plugin/highlight/monokai.css +71 -0
  30. package/skills/jpage-presentation/assets/plugin/highlight/plugin.js +439 -0
  31. package/skills/jpage-presentation/assets/plugin/notes/notes.js +1 -0
  32. package/skills/jpage-presentation/assets/reveal-base.css +9 -0
  33. package/skills/jpage-presentation/assets/reveal.js +9 -0
  34. package/skills/jpage-presentation/assets/themes/academic.css +68 -0
  35. package/skills/jpage-presentation/assets/themes/business.css +64 -0
  36. package/skills/jpage-presentation/assets/themes/creative.css +81 -0
  37. package/skills/jpage-presentation/assets/themes/minimal.css +117 -0
  38. package/skills-registry.js +0 -6
  39. package/test/dispatch-bench.js +0 -3
  40. package/test/integration/cli.test.js +93 -0
  41. package/test/integration/skills.test.js +27 -5
  42. package/test/perf-harness.js +0 -9
  43. package/test/unit/fts.test.js +0 -1
  44. package/.claude/settings.local.json +0 -68
@@ -236,7 +236,10 @@ html.light .theme-toggle .icon-sun { display: block; }
236
236
  color: #fff;
237
237
  border-color: var(--primary);
238
238
  }
239
- .btn-copy-link { color: var(--primary); border-color: var(--primary); }
239
+ /* 图标按钮(btn-small 内仅放 SVG):方形、与星标/更多按钮视觉一致 */
240
+ .btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; color: var(--text-secondary); }
241
+ .btn-icon svg { width: 16px; height: 16px; }
242
+ .btn-copy-link:hover { color: var(--primary); }
240
243
  .btn-copy-link:hover { background: var(--primary); color: #fff; }
241
244
 
242
245
  .auth-message {
@@ -1350,6 +1353,10 @@ html:not(.light) {
1350
1353
  .skeleton-w60 { width: 60%; }
1351
1354
  .skeleton-w40 { width: 40%; }
1352
1355
 
1356
+ .skeleton-card { display: block; padding: 0; }
1357
+ .skeleton-card-thumb { height: 160px; margin-bottom: 12px; border-radius: 6px 6px 0 0; background: linear-gradient(90deg, var(--border) 25%, rgba(0,0,0,.06) 50%, var(--border) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; }
1358
+ .skeleton-card .skeleton-line { margin: 0 14px 8px; }
1359
+
1353
1360
  @keyframes shimmer {
1354
1361
  0% { background-position: 200% 0; }
1355
1362
  100% { background-position: -200% 0; }
@@ -2317,6 +2324,85 @@ html:not(.light) {
2317
2324
  font-weight: 600;
2318
2325
  }
2319
2326
 
2327
+ /* CLI tab:渲染后的 Markdown 文档(标题/段落/列表/代码块/表格) */
2328
+ .mcp-cli-doc {
2329
+ font-size: 13px;
2330
+ line-height: 1.6;
2331
+ color: var(--text);
2332
+ }
2333
+ .mcp-cli-doc h1 {
2334
+ font-size: 18px;
2335
+ margin: 0 0 12px;
2336
+ }
2337
+ .mcp-cli-doc h2 {
2338
+ font-size: 14px;
2339
+ margin: 18px 0 8px;
2340
+ color: var(--text);
2341
+ }
2342
+ .mcp-cli-doc p {
2343
+ margin: 0 0 10px;
2344
+ }
2345
+ .mcp-cli-doc ul,
2346
+ .mcp-cli-doc ol {
2347
+ margin: 0 0 10px;
2348
+ padding-left: 20px;
2349
+ }
2350
+ .mcp-cli-doc li {
2351
+ margin-bottom: 4px;
2352
+ }
2353
+ .mcp-cli-doc blockquote {
2354
+ margin: 0 0 10px;
2355
+ padding: 8px 12px;
2356
+ border-left: 3px solid var(--primary);
2357
+ background: var(--bg);
2358
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
2359
+ color: var(--text-secondary);
2360
+ }
2361
+ .mcp-cli-doc blockquote p {
2362
+ margin: 0;
2363
+ }
2364
+ .mcp-cli-doc code {
2365
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2366
+ font-size: 12px;
2367
+ background: var(--bg);
2368
+ padding: 1px 5px;
2369
+ border-radius: 3px;
2370
+ }
2371
+ /* 代码块(非行内)复用 .mcp-config-code 的样式,保持视觉一致 */
2372
+ .mcp-cli-doc pre {
2373
+ background: var(--bg);
2374
+ border-radius: var(--radius-sm);
2375
+ padding: 12px 14px;
2376
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2377
+ font-size: 12px;
2378
+ line-height: 1.55;
2379
+ white-space: pre-wrap;
2380
+ word-break: break-word;
2381
+ margin: 0 0 10px;
2382
+ overflow-x: auto;
2383
+ }
2384
+ .mcp-cli-doc pre code {
2385
+ background: none;
2386
+ padding: 0;
2387
+ }
2388
+ .mcp-cli-doc table {
2389
+ width: 100%;
2390
+ border-collapse: collapse;
2391
+ margin: 0 0 12px;
2392
+ font-size: 12px;
2393
+ }
2394
+ .mcp-cli-doc th,
2395
+ .mcp-cli-doc td {
2396
+ border: 1px solid var(--border);
2397
+ padding: 6px 10px;
2398
+ text-align: left;
2399
+ vertical-align: top;
2400
+ }
2401
+ .mcp-cli-doc th {
2402
+ background: var(--bg);
2403
+ font-weight: 600;
2404
+ }
2405
+
2320
2406
  html:not(.light) {
2321
2407
  .mcp-status-on {
2322
2408
  background: rgba(22, 163, 74, 0.15);
@@ -2716,6 +2802,43 @@ html:not(.light) {
2716
2802
  .ct-card-thumb { background: linear-gradient(135deg, #1e293b, #334155); }
2717
2803
  }
2718
2804
 
2805
+ /* --- 文件列表视图切换 / 卡片视图 --- */
2806
+ .view-toggle { display: flex; gap: 2px; margin-left: auto; background: var(--bg-secondary, #f1f5f9); border: 1px solid var(--border); border-radius: 999px; padding: 2px; }
2807
+ .view-toggle-btn { min-width: 30px; height: 28px; padding: 0 10px; border: none; border-radius: 999px; background: transparent; color: var(--text-secondary); font-size: 16px; line-height: 1; cursor: pointer; transition: background .15s, color .15s; display: inline-flex; align-items: center; justify-content: center; }
2808
+ .view-toggle-btn:hover { color: var(--text); }
2809
+ .view-toggle-btn.active { background: var(--card); color: var(--primary); box-shadow: 0 1px 3px var(--shadow); }
2810
+
2811
+ .file-list.view-card { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
2812
+
2813
+ .file-card { position: relative; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 14px; cursor: pointer; transition: box-shadow .2s, border-color .2s; display: flex; flex-direction: column; gap: 8px; }
2814
+ .file-card:hover { box-shadow: 0 2px 8px var(--shadow); border-color: var(--primary); }
2815
+ .file-card.selected { border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary); }
2816
+ .file-card-thumb { height: 160px; position: relative; overflow: hidden; background: linear-gradient(135deg, var(--bg-secondary, #f1f5f9), var(--border)); margin: -14px -14px 8px; border-radius: 6px 6px 0 0; }
2817
+ .file-card-thumb-wrap { width: 1024px; height: 640px; transform: scale(0.25); transform-origin: top left; pointer-events: none; opacity: 0; transition: opacity .25s; }
2818
+ .file-card-thumb-wrap.loaded { opacity: 1; }
2819
+ .file-card-thumb-iframe { width: 100%; height: 100%; border: none; background: #fff; }
2820
+ .file-card-thumb-loading { position: absolute; inset: 0; background: linear-gradient(135deg, var(--bg-secondary, #f1f5f9), var(--border)); animation: ct-thumb-pulse 1.5s ease-in-out infinite; }
2821
+ .file-card-icon-btn { position: absolute; top: 6px; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 999px; background: rgba(255,255,255,.85); color: var(--text-secondary); font-size: 14px; cursor: pointer; z-index: 2; transition: background .15s, color .15s; }
2822
+ .file-card-icon-btn svg { width: 15px; height: 15px; }
2823
+ .file-card-star { right: 6px; } /* 星标靠最右 */
2824
+ .file-card-copy { right: 40px; } /* 复制链接在星标左侧(28 宽 + 6 间距 + 6 边距)*/
2825
+ .file-card-star.starred { color: #f59e0b; }
2826
+ .file-card-icon-btn:hover { background: #fff; color: var(--text); }
2827
+ .file-card-star.starred:hover { color: #f59e0b; }
2828
+ .file-card-name { font-size: 14px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.4; word-break: break-all; }
2829
+ .file-card-badges { display: flex; flex-wrap: wrap; gap: 4px; }
2830
+ .file-card-badges .file-badge { font-size: 10px; padding: 1px 6px; line-height: 1.5; }
2831
+ .file-card-footer { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-secondary); margin-top: auto; }
2832
+ html:not(.light) {
2833
+ .file-card-thumb { background: linear-gradient(135deg, #1e293b, #334155); }
2834
+ .file-card-icon-btn { background: rgba(30,41,59,.85); color: #94a3b8; }
2835
+ .file-card-icon-btn:hover { background: #334155; color: #e2e8f0; }
2836
+ .file-card-star.starred { color: #fbbf24; }
2837
+ }
2838
+ @media (prefers-reduced-motion: reduce) {
2839
+ .file-card-thumb-loading { animation: none; }
2840
+ }
2841
+
2719
2842
  /* --- 落地页 --- */
2720
2843
  .landing-page { min-height: 100vh; background: var(--bg); display: flex; flex-direction: column; }
2721
2844
  .landing-nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: transparent; transition: background .3s, border-color .3s, box-shadow .3s; border-bottom: 1px solid transparent; }
@@ -2823,17 +2946,21 @@ html:not(.light) {
2823
2946
  .ver:nth-child(3):hover .ver-dot { background: #06b6d4; box-shadow: 0 0 18px rgba(6,182,212,.5); }
2824
2947
  .ver:nth-child(4) .ver-dot { border-color: #f59e0b; }
2825
2948
  .ver:nth-child(4):hover .ver-dot { background: #f59e0b; box-shadow: 0 0 18px rgba(245,158,11,.5); }
2949
+ .ver:nth-child(5) .ver-dot { border-color: #10b981; }
2950
+ .ver:nth-child(5):hover .ver-dot { background: #10b981; box-shadow: 0 0 18px rgba(16,185,129,.5); }
2826
2951
  .ver-card { background: var(--card); border: 1px solid var(--border); border-radius: 14px; padding: 28px 28px 24px; backdrop-filter: blur(8px); transition: all .4s; }
2827
2952
  .ver:hover .ver-card { border-color: var(--primary); box-shadow: var(--glow); }
2828
2953
  .ver:nth-child(2):hover .ver-card { border-color: #8b5cf6; box-shadow: 0 0 24px rgba(139,92,246,.2); }
2829
2954
  .ver:nth-child(3):hover .ver-card { border-color: #06b6d4; box-shadow: 0 0 24px rgba(6,182,212,.2); }
2830
2955
  .ver:nth-child(4):hover .ver-card { border-color: #f59e0b; box-shadow: 0 0 24px rgba(245,158,11,.2); }
2956
+ .ver:nth-child(5):hover .ver-card { border-color: #10b981; box-shadow: 0 0 24px rgba(16,185,129,.2); }
2831
2957
  .ver-head { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; flex-wrap: wrap; }
2832
2958
  .ver-num { font-family: ui-monospace, "SF Mono", monospace; font-size: 13px; font-weight: 700; letter-spacing: 1px; padding: 3px 10px; border-radius: 6px; }
2833
2959
  .ver:nth-child(1) .ver-num { color: var(--primary); background: rgba(99,102,241,.12); }
2834
2960
  .ver:nth-child(2) .ver-num { color: #8b5cf6; background: rgba(139,92,246,.10); }
2835
2961
  .ver:nth-child(3) .ver-num { color: #06b6d4; background: rgba(6,182,212,.10); }
2836
2962
  .ver:nth-child(4) .ver-num { color: #f59e0b; background: rgba(245,158,11,.10); }
2963
+ .ver:nth-child(5) .ver-num { color: #10b981; background: rgba(16,185,129,.10); }
2837
2964
  .ver-date { font-family: ui-monospace, "SF Mono", monospace; font-size: 12px; color: var(--text-secondary); }
2838
2965
  .ver-title { font-size: 20px; font-weight: 700; flex: 1; }
2839
2966
  .ver-theme { font-size: 13px; color: var(--text-secondary); font-style: italic; padding: 2px 10px; border-radius: 4px; background: rgba(255,255,255,.04); }
package/public/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <meta name="description" content="即页,拖入 HTML 或 Markdown 文件,即刻获得在线预览页面。">
7
7
  <title>即页 — 拖入文件,即刻成页</title>
8
- <link rel="stylesheet" href="/css/style.css?v=1.5.0">
8
+ <link rel="stylesheet" href="/css/style.css?v=1.6.2">
9
9
  </head>
10
10
  <body>
11
11
  <div id="app"></div>
@@ -52,7 +52,7 @@
52
52
  <section class="landing-sec" id="versions">
53
53
  <div class="landing-sec-inner">
54
54
  <div class="landing-sec-tag">Version History</div>
55
- <h2 class="landing-sec-title">四次进化,四个问题的答案</h2>
55
+ <h2 class="landing-sec-title">五次进化,五个问题的答案</h2>
56
56
  <div class="landing-versions">
57
57
  <div class="ver">
58
58
  <div class="ver-dot"></div>
@@ -135,6 +135,27 @@
135
135
  </div>
136
136
  </div>
137
137
  </div>
138
+ <div class="ver">
139
+ <div class="ver-dot"></div>
140
+ <div class="ver-card">
141
+ <div class="ver-head">
142
+ <span class="ver-num">V1.5</span>
143
+ <span class="ver-date">2026-06-19</span>
144
+ <span class="ver-title">双客户端 + AI 演示 + 卡片视图</span>
145
+ <span class="ver-theme">「快速、多样、直观地发布」</span>
146
+ </div>
147
+ <p class="ver-desc">jpage CLI 成为与 MCP 并列的第二客户端入口——大文件 multipart 流式上传,告别 base64。新增 jpage-presentation Skill,AI 一句话生成 reveal.js 幻灯片。文件列表卡片视图用 iframe 实时缩略图,所见即所得。</p>
148
+ <div class="ver-highlights">
149
+ <div class="hl"><span class="hl-icon">⌨️</span>jpage CLI(11 命令 · npm 包)</div>
150
+ <div class="hl"><span class="hl-icon">🎞️</span>jpage-presentation 幻灯片 Skill</div>
151
+ <div class="hl"><span class="hl-icon">🃏</span>卡片视图 iframe 实时缩略图</div>
152
+ <div class="hl"><span class="hl-icon">🎨</span>reveal.js 4 主题(商务/学术/创意/极简)</div>
153
+ <div class="hl"><span class="hl-icon">🔗</span>复制链接改为图标按钮</div>
154
+ <div class="hl"><span class="hl-icon">⚡</span>multipart 流式上传(CLI)</div>
155
+ <div class="hl"><span class="hl-icon">🛠️</span>双客户端入口拆分 + 缓存刷新修复</div>
156
+ </div>
157
+ </div>
158
+ </div>
138
159
  </div>
139
160
  </div>
140
161
  </section>
@@ -268,6 +289,10 @@ Skills 管理
268
289
  <button type="button" class="settings-menu-item" role="menuitem" id="menu-item-mcp">
269
290
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
270
291
  MCP 配置
292
+ </button>
293
+ <button type="button" class="settings-menu-item" role="menuitem" id="menu-item-cli">
294
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
295
+ CLI 工具
271
296
  </button>
272
297
  <div class="settings-menu-divider" role="separator"></div>
273
298
  <button type="button" class="settings-menu-item" role="menuitem" id="menu-item-tokens">
@@ -350,6 +375,10 @@ MCP 配置
350
375
  <div class="filter-dropdown-menu" id="category-filter-menu"></div>
351
376
  </div>
352
377
  </div>
378
+ <div class="view-toggle" role="tablist" aria-label="展示形式">
379
+ <button type="button" class="view-toggle-btn active" data-view="list" role="tab" title="列表视图" aria-label="列表视图">≡</button>
380
+ <button type="button" class="view-toggle-btn" data-view="card" role="tab" title="卡片视图" aria-label="卡片视图">▦</button>
381
+ </div>
353
382
  </div>
354
383
  <div class="file-list" id="file-list" aria-busy="false"></div>
355
384
  <div class="empty-state" id="empty-state" style="display:none">
@@ -518,6 +547,25 @@ MCP 配置
518
547
  </div>
519
548
  </div>
520
549
 
550
+ <div class="modal-backdrop" id="cli-config-modal" hidden aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="cli-config-title">
551
+ <div class="modal-panel">
552
+ <div class="modal-header">
553
+ <h2 id="cli-config-title">jpage CLI</h2>
554
+ <button type="button" class="btn btn-small modal-close" id="cli-config-close" aria-label="关闭">×</button>
555
+ </div>
556
+ <div class="modal-body">
557
+ <p class="mcp-config-hint"><code>jpage</code> 是即页的命令行工具,与 MCP 是并列的两个客户端入口(都架在同一套 REST API 上)。有 Bash 的场景(Claude Code、脚本、CI)用 CLI 上传更快更省。</p>
558
+ <div class="mcp-config-block">
559
+ <button type="button" class="btn btn-small mcp-copy-btn" id="cli-copy-guide">复制文档</button>
560
+ <div class="mcp-cli-doc cli-detail" id="cli-detail">加载中…</div>
561
+ </div>
562
+ </div>
563
+ <div class="modal-footer">
564
+ <button type="button" class="btn btn-small" id="cli-config-dismiss">关闭</button>
565
+ </div>
566
+ </div>
567
+ </div>
568
+
521
569
  <!-- Skills 列表弹窗 -->
522
570
  <div class="modal-backdrop" id="skills-list-modal" hidden aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="skills-list-title">
523
571
  <div class="modal-panel modal-panel-wide">
@@ -850,6 +898,6 @@ MCP 配置
850
898
  </div>
851
899
  </div>
852
900
 
853
- <script type="module" src="/js/app.js?v=1.4.1"></script>
901
+ <script type="module" src="/js/app.js?v=1.5.3"></script>
854
902
  </body>
855
903
  </html>
package/public/js/app.js CHANGED
@@ -5,7 +5,6 @@
5
5
 
6
6
  import { api } from './api.js';
7
7
  import { dialogModal } from './components/dialog.js';
8
- import { toast } from './components/toast.js';
9
8
  import { initTheme, setupThemeToggle } from './theme.js';
10
9
 
11
10
  const state = {
@@ -32,10 +31,13 @@ function navigate(path) {
32
31
  }
33
32
 
34
33
  // 动态加载各路由模块(esbuild 据此做代码分割,产出独立 chunk)
35
- async function loadHome() { const m = await import('./pages/home.js'); return m.renderHome; }
36
- async function loadLogin() { const m = await import('./pages/login.js'); return m.renderLogin; }
37
- async function loadLanding() { const m = await import('./pages/landing.js'); return m.renderLanding; }
38
- async function loadPreview() { const m = await import('./pages/preview.js'); return m.renderPreview; }
34
+ // 动态 import 带字面量版本串:发版时连同 index.html app.js ?v= 一起改,
35
+ // 让浏览器在开发模式(直接服务源文件、无内容哈希文件名)下也重新拉取各页面 chunk,
36
+ // 绕过 immutable 长缓存。字面量(非模板)能被 esbuild 正确分割。
37
+ async function loadHome() { const m = await import('./pages/home.js?v=1.5.2'); return m.renderHome; }
38
+ async function loadLogin() { const m = await import('./pages/login.js?v=1.5.2'); return m.renderLogin; }
39
+ async function loadLanding() { const m = await import('./pages/landing.js?v=1.5.2'); return m.renderLanding; }
40
+ async function loadPreview() { const m = await import('./pages/preview.js?v=1.5.2'); return m.renderPreview; }
39
41
 
40
42
  function route() {
41
43
  const hash = location.hash.replace('#', '') || '/';
@@ -79,7 +81,7 @@ function route() {
79
81
  } else if (hash === '/register') {
80
82
  loadLogin().then((renderLogin) => { renderLogin(appEl, 'register'); setupThemeToggle(appEl); });
81
83
  } else {
82
- loadLanding().then((renderLanding) => { renderLanding(appEl, null); setupThemeToggle(appEl); });
84
+ loadLanding().then((renderLanding) => { renderLanding(appEl); setupThemeToggle(appEl); });
83
85
  }
84
86
  }
85
87
 
@@ -1,6 +1,6 @@
1
1
  // 内容模板市场:浏览、上传、详情
2
2
 
3
- import { api, API_BASE } from '../api.js';
3
+ import { api } from '../api.js';
4
4
  import { toast } from '../components/toast.js';
5
5
  import { dialogModal } from '../components/dialog.js';
6
6
  import { escapeHtml, relativeTime, openModal, closeModal } from '../utils.js';
@@ -16,9 +16,63 @@ let allCategories = [];
16
16
  const selectedFileIds = new Set();
17
17
  let lastCheckedIndex = -1;
18
18
  let skillModalCurrent = null;
19
- const allTemplates = [];
20
19
  let searchResults = null;
21
20
 
21
+ // ---------- 视图模式(列表 / 卡片) ----------
22
+ const FILE_VIEW_KEY = 'jpage-file-view';
23
+ let viewMode = (() => { try { return localStorage.getItem(FILE_VIEW_KEY) === 'card' ? 'card' : 'list'; } catch { return 'list'; } })();
24
+ function setViewMode(mode) {
25
+ viewMode = mode;
26
+ try { localStorage.setItem(FILE_VIEW_KEY, mode); } catch {}
27
+ }
28
+
29
+ // 卡片缩略图懒加载管线(镜像 content-templates.js 的 thumbObserver:rootMargin 预加载 + 最多 3 并发)
30
+ // 注意:不能用 file-id Set 去重——翻页/筛选时 list.innerHTML='' 会销毁并重建卡片 DOM,
31
+ // 新卡片是全新元素,必须重新挂 iframe(会命中浏览器/渲染缓存,开销小)。
32
+ // 去重改为按「该卡片是否已有 iframe」判断,避免切回已访问过的页时缩略图卡在灰色占位。
33
+ let cardThumbObserver = null;
34
+ let cardThumbActive = 0;
35
+ const CARD_THUMB_MAX = 3;
36
+ const cardThumbQueue = [];
37
+ function ensureCardThumbObserver() {
38
+ if (cardThumbObserver) return cardThumbObserver;
39
+ cardThumbObserver = new IntersectionObserver(entries => {
40
+ entries.forEach(entry => {
41
+ if (!entry.isIntersecting) return;
42
+ const card = entry.target;
43
+ cardThumbObserver.unobserve(card);
44
+ enqueueCardThumb(card);
45
+ });
46
+ }, { rootMargin: '200px' });
47
+ return cardThumbObserver;
48
+ }
49
+ function enqueueCardThumb(card) {
50
+ if (cardThumbActive < CARD_THUMB_MAX) {
51
+ cardThumbActive++;
52
+ loadCardThumb(card).finally(() => {
53
+ cardThumbActive--;
54
+ if (cardThumbQueue.length) enqueueCardThumb(cardThumbQueue.shift());
55
+ });
56
+ } else {
57
+ cardThumbQueue.push(card);
58
+ }
59
+ }
60
+ // iframe.src 直接指向已有的 /api/files/:id/render —— HTML / Markdown / ZIP bundle 入口页均可渲染;
61
+ // 同源 iframe 默认携带 session cookie,用户自己的私有文件也能正常加载(loadFileWithPrivacy 放行 uploaded_by === userId)。
62
+ function loadCardThumb(card) {
63
+ const thumb = card.querySelector('.file-card-thumb');
64
+ // 已有 iframe(正在加载或已加载)则跳过,防止同一卡片重复挂载
65
+ if (!thumb || thumb.querySelector('.file-card-thumb-iframe')) return Promise.resolve();
66
+ thumb.innerHTML = '<div class="file-card-thumb-wrap"><iframe class="file-card-thumb-iframe" loading="lazy" title="预览"></iframe></div>';
67
+ const wrap = thumb.querySelector('.file-card-thumb-wrap');
68
+ const iframe = thumb.querySelector('.file-card-thumb-iframe');
69
+ return new Promise(resolve => {
70
+ iframe.addEventListener('load', () => { wrap.classList.add('loaded'); resolve(); }, { once: true });
71
+ iframe.addEventListener('error', () => resolve(), { once: true });
72
+ iframe.src = API_BASE + '/api/files/' + card.dataset.fileId + '/render';
73
+ });
74
+ }
75
+
22
76
  // ---------- Home Page ----------
23
77
  function renderHome(container) {
24
78
  const tmpl = document.getElementById('home-template');
@@ -76,6 +130,7 @@ function renderHome(container) {
76
130
 
77
131
  setupUpload(container);
78
132
  setupFileFilter(container);
133
+ setupViewToggle(container);
79
134
  loadTagsAndCategories(container);
80
135
  loadFiles(container);
81
136
  setupSkillModal();
@@ -154,6 +209,11 @@ function renderHome(container) {
154
209
  settingsBtn.setAttribute('aria-expanded', 'false');
155
210
  openMcpConfigModal();
156
211
  });
212
+ settingsDropdown.querySelector('#menu-item-cli').addEventListener('click', () => {
213
+ settingsDropdown.classList.remove('open');
214
+ settingsBtn.setAttribute('aria-expanded', 'false');
215
+ openCliConfigModal();
216
+ });
157
217
  settingsDropdown.querySelector('#menu-item-tokens')?.addEventListener('click', () => {
158
218
  settingsDropdown.classList.remove('open');
159
219
  settingsBtn.setAttribute('aria-expanded', 'false');
@@ -306,7 +366,7 @@ async function loadFiles(container, page) {
306
366
 
307
367
  list.setAttribute('aria-busy', 'true');
308
368
  list.classList.add('is-loading');
309
- list.innerHTML = buildSkeletonCards(Math.min(pagination.limit, 5));
369
+ list.innerHTML = buildSkeletonCards(Math.min(pagination.limit, 5), viewMode);
310
370
  empty.style.display = 'none';
311
371
  countEl.textContent = '';
312
372
 
@@ -447,6 +507,11 @@ async function doBatchAction(container, action, data) {
447
507
  }
448
508
 
449
509
  function renderFileList(container, list, files) {
510
+ if (viewMode === 'card') {
511
+ renderCardList(container, list, files);
512
+ return;
513
+ }
514
+ list.classList.remove('view-card'); // 列表视图,恢复单列布局
450
515
  const selectAllCb = container.querySelector('#select-all-checkbox');
451
516
  if (selectAllCb) {
452
517
  selectAllCb.checked = false;
@@ -504,7 +569,7 @@ function renderFileList(container, list, files) {
504
569
  </div>
505
570
  <div class="file-actions">
506
571
  <button type="button" class="btn btn-small btn-star ${f.starred ? 'starred' : ''}" data-id="${f.id}">${f.starred ? '★' : '☆'}</button>
507
- <button type="button" class="btn btn-small btn-copy-link" data-id="${f.id}">复制链接</button>
572
+ <button type="button" class="btn btn-small btn-copy-link" data-id="${f.id}" title="复制链接" aria-label="复制链接"><svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
508
573
  <div class="file-more-dropdown">
509
574
  <button type="button" class="btn btn-small file-more-trigger" title="更多操作">⋯</button>
510
575
  <div class="file-more-menu">
@@ -627,6 +692,75 @@ function renderFileList(container, list, files) {
627
692
  });
628
693
  }
629
694
 
695
+ // 卡片视图:每张卡片含一个实时 iframe 缩略图(懒加载)+ 文件名 + 标签 + 状态。
696
+ function renderCardList(container, list, files) {
697
+ // 切换容器为网格布局
698
+ list.classList.add('view-card');
699
+ // 卡片视图不支持全选(空间小),隐藏表头的全选 checkbox
700
+ const selectAllCb = container.querySelector('#select-all-checkbox');
701
+ if (selectAllCb) selectAllCb.checked = false;
702
+ const observer = ensureCardThumbObserver();
703
+
704
+ files.forEach((f) => {
705
+ const el = document.createElement('div');
706
+ el.className = 'file-card';
707
+ el.dataset.fileId = f.id;
708
+ if (selectedFileIds.has(f.id)) el.classList.add('selected');
709
+ const safeName = escapeHtml(f.original_name);
710
+ const isPublic = !!f.is_public;
711
+ const iconText = f.is_bundle ? 'ZIP' : (f.file_type === 'markdown' ? 'MD' : 'HTML');
712
+ const privacyBadge = isPublic
713
+ ? '<span class="file-badge file-badge-public">公开</span>'
714
+ : '<span class="file-badge file-badge-private">私有</span>';
715
+ const versionBadge = f.version_count > 0
716
+ ? `<span class="file-badge file-badge-version">v${f.version_count + 1}</span>` : '';
717
+ const tagBadges = (f.tags || []).slice(0, 3).map(t =>
718
+ `<span class="file-badge file-badge-tag" data-tag-id="${t.id}">${escapeHtml(t.name)}</span>`).join('');
719
+ const size = formatSize(f.size);
720
+ const timeStr = relativeTime(f.updated_at || f.created_at);
721
+
722
+ el.innerHTML = `
723
+ <div class="file-card-thumb" aria-hidden="true">
724
+ <div class="file-card-thumb-loading"></div>
725
+ </div>
726
+ <button type="button" class="file-card-icon-btn file-card-star ${f.starred ? 'starred' : ''}" data-id="${f.id}" aria-label="收藏" title="收藏">${f.starred ? '★' : '☆'}</button>
727
+ <button type="button" class="file-card-icon-btn file-card-copy" data-id="${f.id}" aria-label="复制链接" title="复制链接"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
728
+ <div class="file-card-name" title="${safeName}">${safeName}</div>
729
+ <div class="file-card-badges"><span class="file-badge file-badge-type">${iconText}</span>${privacyBadge}${versionBadge}${tagBadges}</div>
730
+ <div class="file-card-footer"><span>${size}</span><span>${timeStr}</span></div>
731
+ `;
732
+ el.setAttribute('role', 'button');
733
+ el.setAttribute('tabindex', '0');
734
+ el.setAttribute('aria-label', `打开 ${f.original_name}`);
735
+
736
+ // 整张卡片点击 → 预览(星标按钮单独拦截)
737
+ const openPreview = () => navigate('/view/' + f.id);
738
+ el.addEventListener('click', openPreview);
739
+ el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPreview(); } });
740
+ el.querySelector('.file-card-star').addEventListener('click', async e => {
741
+ e.stopPropagation();
742
+ await toggleStar(f.id, f.starred);
743
+ loadFiles(container);
744
+ });
745
+ el.querySelector('.file-card-copy').addEventListener('click', e => {
746
+ e.stopPropagation();
747
+ doCopyLink(f.share_key);
748
+ });
749
+ el.querySelectorAll('.file-badge-tag').forEach(badge => {
750
+ badge.addEventListener('click', e => {
751
+ e.stopPropagation();
752
+ filterState.tagId = parseInt(badge.dataset.tagId);
753
+ renderFilterDropdowns(container);
754
+ loadFiles(container, 1);
755
+ });
756
+ });
757
+
758
+ // 注册懒加载:卡片滚到可视区附近才挂 iframe
759
+ observer.observe(el);
760
+ list.appendChild(el);
761
+ });
762
+ }
763
+
630
764
  function renderPagination(container) {
631
765
  let wrap = container.querySelector('#pagination');
632
766
  if (!wrap) {
@@ -694,6 +828,28 @@ function buildPageNumbers(current, total) {
694
828
  return pages;
695
829
  }
696
830
 
831
+ function setupViewToggle(container) {
832
+ const buttons = container.querySelectorAll('.view-toggle-btn');
833
+ const selectAllWrap = container.querySelector('.select-all-wrap'); // 卡片视图无单卡 checkbox,隐藏全选
834
+ const syncAllSelect = () => { if (selectAllWrap) selectAllWrap.hidden = (viewMode === 'card'); };
835
+ // 根据 viewMode 同步按钮的 active 态(首次进入 / 刷新后恢复持久化选择)
836
+ buttons.forEach(b => b.classList.toggle('active', b.dataset.view === viewMode));
837
+ syncAllSelect();
838
+ buttons.forEach(btn => {
839
+ btn.addEventListener('click', () => {
840
+ if (viewMode === btn.dataset.view) return;
841
+ // 切换前断开旧 observer,避免卡片 DOM 已销毁仍持有引用造成泄漏
842
+ if (cardThumbObserver) { cardThumbObserver.disconnect(); }
843
+ cardThumbQueue.length = 0;
844
+ cardThumbActive = 0;
845
+ setViewMode(btn.dataset.view);
846
+ buttons.forEach(b => b.classList.toggle('active', b === btn));
847
+ syncAllSelect();
848
+ applyFilters(container); // 复用现有重渲染流,重建列表/卡片
849
+ });
850
+ });
851
+ }
852
+
697
853
  function setupFileFilter(container) {
698
854
  const searchInput = container.querySelector('#search-input');
699
855
  const searchClear = container.querySelector('#search-clear');
@@ -798,7 +954,7 @@ async function doRename(container, id, currentName) {
798
954
  value: currentName,
799
955
  validate: v => {
800
956
  if (!v.trim()) return '文件名不能为空';
801
- if (/[\/\\]/.test(v)) return '文件名不能包含 / 或 \\';
957
+ if (/[/\\]/.test(v)) return '文件名不能包含 / 或 \\';
802
958
  return null;
803
959
  },
804
960
  });
@@ -1006,7 +1162,8 @@ function openMcpConfigModal() {
1006
1162
  <code class="mcp-value">${data.tokens.map(t => esc(t.token_prefix) + '…').join(', ')}</code>
1007
1163
  </div>` : ''}
1008
1164
  `;
1009
- // 多客户端 Tab:共用同一份标准 JSON,差异仅在目标文件路径/说明文字
1165
+ // MCP 客户端 Tab:共用同一份标准 JSON,差异仅在目标文件路径/说明文字。
1166
+ // CLI 不在此弹窗内——它走独立的「CLI 工具」菜单 + /api/cli/guide。
1010
1167
  const configs = (data.configs && data.configs.length > 0)
1011
1168
  ? data.configs
1012
1169
  : [{ id: 'generic', label: '通用', path: '', config: data.config }];
@@ -1031,6 +1188,7 @@ function openMcpConfigModal() {
1031
1188
  const setConfig = (idx) => {
1032
1189
  const c = configs[idx];
1033
1190
  if (!c) return;
1191
+ // MCP 客户端 tab:显示 JSON
1034
1192
  activeConfigJson = JSON.stringify(c.config, null, 2);
1035
1193
  codeEl.textContent = activeConfigJson;
1036
1194
  pathEl.textContent = c.path || '';
@@ -1044,12 +1202,16 @@ function openMcpConfigModal() {
1044
1202
  });
1045
1203
  const copyBtn = document.getElementById('mcp-copy-config');
1046
1204
  if (copyBtn) {
1047
- copyBtn.addEventListener('click', () => {
1048
- navigator.clipboard.writeText(activeConfigJson).then(() => {
1205
+ copyBtn.addEventListener('click', async () => {
1206
+ // PR #9:navigator.clipboard 优先,不支持/失败时回退 execCommand(copyToClipboard)
1207
+ const ok = await copyToClipboard(activeConfigJson);
1208
+ if (ok) {
1049
1209
  toast('已复制到剪贴板');
1050
1210
  copyBtn.textContent = '已复制';
1051
1211
  setTimeout(() => { copyBtn.textContent = '复制'; }, 2000);
1052
- }).catch(() => toast('复制失败', 'error'));
1212
+ } else {
1213
+ toast('复制失败', 'error');
1214
+ }
1053
1215
  });
1054
1216
  }
1055
1217
  } else {
@@ -1079,6 +1241,54 @@ function closeMcpConfigModal() {
1079
1241
  modal.setAttribute('aria-hidden', 'true');
1080
1242
  }
1081
1243
 
1244
+ // ---------- CLI Config Modal ----------
1245
+ // CLI 与 MCP 是并列的两个客户端入口,各自独立弹窗。这里只渲染 CLI 用法文档。
1246
+ function openCliConfigModal() {
1247
+ const modal = document.getElementById('cli-config-modal');
1248
+ if (!modal) return;
1249
+
1250
+ if (!modal.dataset.bound) {
1251
+ modal.dataset.bound = '1';
1252
+ modal.querySelector('#cli-config-close').addEventListener('click', closeCliConfigModal);
1253
+ modal.querySelector('#cli-config-dismiss').addEventListener('click', closeCliConfigModal);
1254
+ modal.addEventListener('click', e => { if (e.target === modal) closeCliConfigModal(); });
1255
+ }
1256
+
1257
+ const detailEl = document.getElementById('cli-detail');
1258
+ detailEl.textContent = '加载中…';
1259
+
1260
+ api('/api/cli/guide').then(data => {
1261
+ // 渲染富文本用法文档;缓存纯文本供「复制文档」按钮使用
1262
+ detailEl.innerHTML = data.guideHtml || '<p>(无文档)</p>';
1263
+ const copyBtn = document.getElementById('cli-copy-guide');
1264
+ if (copyBtn) {
1265
+ copyBtn.onclick = async () => {
1266
+ const ok = await copyToClipboard(data.guideText || '');
1267
+ if (ok) {
1268
+ toast('已复制到剪贴板');
1269
+ copyBtn.textContent = '已复制';
1270
+ setTimeout(() => { copyBtn.textContent = '复制文档'; }, 2000);
1271
+ } else {
1272
+ toast('复制失败', 'error');
1273
+ }
1274
+ };
1275
+ }
1276
+ modal.hidden = false;
1277
+ modal.setAttribute('aria-hidden', 'false');
1278
+ }).catch(e => {
1279
+ detailEl.innerHTML = `<p style="color:var(--danger)">加载失败: ${escapeHtml(e.message)}</p>`;
1280
+ modal.hidden = false;
1281
+ modal.setAttribute('aria-hidden', 'false');
1282
+ });
1283
+ }
1284
+
1285
+ function closeCliConfigModal() {
1286
+ const modal = document.getElementById('cli-config-modal');
1287
+ if (!modal) return;
1288
+ modal.hidden = true;
1289
+ modal.setAttribute('aria-hidden', 'true');
1290
+ }
1291
+
1082
1292
  // ---------- Skills List Modal ----------
1083
1293
  function openSkillsListModal() {
1084
1294
  const modal = document.getElementById('skills-list-modal');
@@ -1218,7 +1428,6 @@ async function createUserDialog() {
1218
1428
 
1219
1429
  // 需要挂到 window 上因为 users table 用了 inline onclick
1220
1430
  async function editUserDialog(id, username, role, email) {
1221
- const ops = ['修改用户名/邮箱', '修改角色', '重置密码'];
1222
1431
  const choice = await dialogModal.confirm({
1223
1432
  title: '编辑用户: ' + username,
1224
1433
  message: '请选择操作',
@@ -3,7 +3,7 @@
3
3
  import { api } from '../api.js';
4
4
  import { state, navigate } from '../app.js';
5
5
 
6
- function renderLanding(container, openModal) {
6
+ function renderLanding(container) {
7
7
  if (state.currentUser) { navigate('/'); return; }
8
8
  const tmpl = document.getElementById('landing-template');
9
9
  container.innerHTML = '';