@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.
Files changed (143) hide show
  1. package/.claude/settings.local.json +68 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +56 -0
  4. package/.github/workflows/ci.yml +43 -0
  5. package/CLAUDE.md +280 -0
  6. package/Dockerfile +44 -0
  7. package/LICENSE +21 -0
  8. package/README.md +433 -0
  9. package/README_EN.md +399 -0
  10. package/bin/args.js +64 -0
  11. package/bin/client.js +93 -0
  12. package/bin/commands/_shared.js +54 -0
  13. package/bin/commands/cat.js +23 -0
  14. package/bin/commands/ls.js +44 -0
  15. package/bin/commands/mv.js +20 -0
  16. package/bin/commands/rm.js +22 -0
  17. package/bin/commands/skills.js +70 -0
  18. package/bin/commands/star.js +23 -0
  19. package/bin/commands/tags.js +97 -0
  20. package/bin/commands/upload.js +84 -0
  21. package/bin/commands/url.js +25 -0
  22. package/bin/commands/whoami.js +29 -0
  23. package/bin/config.js +85 -0
  24. package/bin/jpage.js +168 -0
  25. package/build.js +112 -0
  26. package/docker-compose.yml +26 -0
  27. package/docs/api.md +438 -0
  28. package/docs/design/005-custom-modal.md +296 -0
  29. package/docs/design/013-file-version-history.md +324 -0
  30. package/docs/design/billing-system.md +600 -0
  31. package/docs/design/db-index-and-healthcheck.md +176 -0
  32. package/docs/design/loading-states.md +209 -0
  33. package/docs/virtual-hosting-feasibility.md +453 -0
  34. package/eslint.config.mjs +172 -0
  35. package/lib/auth-state.js +15 -0
  36. package/lib/categories.js +20 -0
  37. package/lib/crypto.js +85 -0
  38. package/lib/csp.js +66 -0
  39. package/lib/db.js +53 -0
  40. package/lib/dispatch.js +103 -0
  41. package/lib/fts.js +81 -0
  42. package/lib/middleware/auth.js +114 -0
  43. package/lib/middleware/files.js +42 -0
  44. package/lib/paths.js +9 -0
  45. package/lib/render-cache.js +48 -0
  46. package/lib/render.js +157 -0
  47. package/lib/templates.js +149 -0
  48. package/lib/util.js +66 -0
  49. package/lib/view-counts.js +59 -0
  50. package/lib/zip.js +192 -0
  51. package/logger.js +16 -0
  52. package/mailer.js +34 -0
  53. package/mcp/constants.js +16 -0
  54. package/mcp/resources.js +74 -0
  55. package/mcp/server.js +43 -0
  56. package/mcp/tools-categories.js +56 -0
  57. package/mcp/tools-content-templates.js +59 -0
  58. package/mcp/tools-files.js +245 -0
  59. package/mcp/tools-tags.js +41 -0
  60. package/mcp/tools-versions.js +57 -0
  61. package/mcp/transport.js +183 -0
  62. package/mcp/util.js +63 -0
  63. package/mcp-server.js +20 -0
  64. package/migrations/001_init_schema.js +25 -0
  65. package/migrations/002_add_share_key.js +33 -0
  66. package/migrations/003_add_roles_and_tokens.js +28 -0
  67. package/migrations/004_add_version_history.js +32 -0
  68. package/migrations/005_tags_starred_categories.js +49 -0
  69. package/migrations/006_zip_bundle.js +17 -0
  70. package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
  71. package/migrations/008_add_fts5.js +6 -0
  72. package/migrations/009_add_link_visits.js +20 -0
  73. package/migrations/010_add_templates_system.js +34 -0
  74. package/migrations/011_content_templates.js +233 -0
  75. package/migrations/012_add_email_and_verification.js +35 -0
  76. package/migrations/013_add_token_encrypted.js +14 -0
  77. package/migrations.js +65 -0
  78. package/package.json +63 -0
  79. package/public/css/style.css +2915 -0
  80. package/public/index.html +855 -0
  81. package/public/js/api.js +22 -0
  82. package/public/js/app.js +94 -0
  83. package/public/js/components/dialog.js +106 -0
  84. package/public/js/components/toast.js +13 -0
  85. package/public/js/pages/content-templates.js +330 -0
  86. package/public/js/pages/home.js +1903 -0
  87. package/public/js/pages/landing.js +158 -0
  88. package/public/js/pages/login.js +175 -0
  89. package/public/js/pages/preview.js +713 -0
  90. package/public/js/theme.js +44 -0
  91. package/public/js/utils.js +67 -0
  92. package/routes/admin.js +136 -0
  93. package/routes/auth.js +365 -0
  94. package/routes/categories.js +90 -0
  95. package/routes/content-templates.js +215 -0
  96. package/routes/files/_shared.js +112 -0
  97. package/routes/files/associations.js +94 -0
  98. package/routes/files/crud.js +139 -0
  99. package/routes/files/detail-serve.js +178 -0
  100. package/routes/files/index.js +38 -0
  101. package/routes/files/list.js +200 -0
  102. package/routes/files/overwrite.js +114 -0
  103. package/routes/files/upload.js +204 -0
  104. package/routes/files/versions.js +166 -0
  105. package/routes/files.js +16 -0
  106. package/routes/skills.js +93 -0
  107. package/routes/tags.js +65 -0
  108. package/routes/tokens.js +110 -0
  109. package/routes/users.js +120 -0
  110. package/server.js +372 -0
  111. package/skills/jpage-content-template/SKILL.md +98 -0
  112. package/skills/jpage-upload/SKILL.md +247 -0
  113. package/skills-registry.js +135 -0
  114. package/templates/academic.html +41 -0
  115. package/templates/dark-pro.html +41 -0
  116. package/templates/default.html +56 -0
  117. package/templates/github.html +67 -0
  118. package/test/browser-harness.js +125 -0
  119. package/test/dispatch-bench.js +74 -0
  120. package/test/helpers/setup.js +45 -0
  121. package/test/integration/admin.test.js +108 -0
  122. package/test/integration/auth.test.js +93 -0
  123. package/test/integration/categories.test.js +103 -0
  124. package/test/integration/cli.test.js +310 -0
  125. package/test/integration/content-templates.test.js +147 -0
  126. package/test/integration/files-security.test.js +248 -0
  127. package/test/integration/files.test.js +139 -0
  128. package/test/integration/share.test.js +79 -0
  129. package/test/integration/skills.test.js +104 -0
  130. package/test/integration/tags.test.js +84 -0
  131. package/test/integration/tokens.test.js +89 -0
  132. package/test/integration/users.test.js +138 -0
  133. package/test/mcp-harness.js +152 -0
  134. package/test/perf-bench.js +108 -0
  135. package/test/perf-harness.js +198 -0
  136. package/test/run-server.sh +15 -0
  137. package/test/unit/cli-args.test.js +88 -0
  138. package/test/unit/cli-config.test.js +89 -0
  139. package/test/unit/crypto.test.js +100 -0
  140. package/test/unit/fts.test.js +52 -0
  141. package/test/unit/render-cache.test.js +76 -0
  142. package/test/unit/util.test.js +81 -0
  143. package/test/unit/zip.test.js +164 -0
@@ -0,0 +1,158 @@
1
+ // 落地页:产品介绍、模板展示
2
+
3
+ import { api } from '../api.js';
4
+ import { state, navigate } from '../app.js';
5
+
6
+ function renderLanding(container, openModal) {
7
+ if (state.currentUser) { navigate('/'); return; }
8
+ const tmpl = document.getElementById('landing-template');
9
+ container.innerHTML = '';
10
+ container.appendChild(tmpl.content.cloneNode(true));
11
+
12
+ const el = container.querySelector('.landing-page');
13
+
14
+ // 导航栏滚动吸顶效果
15
+ const nav = el.querySelector('.landing-nav');
16
+ const onScroll = () => {
17
+ if (window.scrollY > 40) {
18
+ nav.classList.add('scrolled');
19
+ } else {
20
+ nav.classList.remove('scrolled');
21
+ }
22
+ };
23
+ window.addEventListener('scroll', onScroll, { passive: true });
24
+ onScroll();
25
+
26
+ // 模板展示(可选区块:若模板里已移除相关元素,则跳过,不影响落地页主体渲染)
27
+ const grid = el.querySelector('#landing-template-grid');
28
+ const emptyEl = el.querySelector('#landing-template-empty');
29
+ const filters = el.querySelectorAll('.scene-filter');
30
+ let currentScene = '';
31
+
32
+ // 关键元素缺失时优雅降级:不初始化模板展示功能
33
+ if (grid && emptyEl) {
34
+ filters.forEach(btn => {
35
+ btn.addEventListener('click', () => {
36
+ filters.forEach(b => b.classList.remove('active'));
37
+ btn.classList.add('active');
38
+ currentScene = btn.dataset.scene;
39
+ loadTemplates();
40
+ });
41
+ });
42
+ }
43
+
44
+ async function loadTemplates() {
45
+ try {
46
+ const params = new URLSearchParams({ limit: '8' });
47
+ if (currentScene) params.set('scene', currentScene);
48
+ const data = await api('/api/content-templates/public?' + params);
49
+ renderTemplateGrid(data.templates);
50
+ } catch {
51
+ renderTemplateGrid([]);
52
+ }
53
+ }
54
+
55
+ function renderTemplateGrid(templates) {
56
+ if (!templates || templates.length === 0) {
57
+ grid.innerHTML = '';
58
+ emptyEl.hidden = false;
59
+ return;
60
+ }
61
+ emptyEl.hidden = true;
62
+ grid.innerHTML = templates.map(t => {
63
+ const sceneLabels = { dashboard: '仪表板', report: '报告', resume: '简历', landing: '落地页', note: '笔记', presentation: '演示', card: '卡片', email: '邮件', other: '其他' };
64
+ const sceneLabel = sceneLabels[t.scene] || t.scene;
65
+ return `
66
+ <div class="landing-template-card" data-id="${t.id}" data-file-type="${t.file_type}">
67
+ <div class="landing-template-thumb">
68
+ <div class="ct-card-thumb-wrap"><iframe class="ct-thumb-iframe" sandbox="allow-scripts"></iframe></div>
69
+ <div class="ct-card-thumb-loading"></div>
70
+ <div class="landing-template-thumb-placeholder">${t.title.charAt(0)}</div>
71
+ </div>
72
+ <div class="landing-template-info">
73
+ <h4>${escapeHtml(t.title)}</h4>
74
+ <div class="landing-template-meta">
75
+ <span class="ct-badge ct-badge-scene">${sceneLabel}</span>
76
+ <span class="ct-badge ct-badge-type">${t.file_type.toUpperCase()}</span>
77
+ ${t.use_count > 0 ? `<span class="landing-template-uses">${t.use_count} 次使用</span>` : ''}
78
+ </div>
79
+ </div>
80
+ </div>`;
81
+ }).join('');
82
+
83
+ const landingThumbObserver = new IntersectionObserver((entries) => {
84
+ entries.forEach(entry => {
85
+ if (!entry.isIntersecting) return;
86
+ const card = entry.target;
87
+ landingThumbObserver.unobserve(card);
88
+ loadLandingThumb(card);
89
+ });
90
+ }, { rootMargin: '200px' });
91
+
92
+ grid.querySelectorAll('.landing-template-card').forEach(card => {
93
+ card.addEventListener('click', () => openTemplatePreview(parseInt(card.dataset.id)));
94
+ landingThumbObserver.observe(card);
95
+ });
96
+ }
97
+
98
+ async function loadLandingThumb(card) {
99
+ const id = parseInt(card.dataset.id);
100
+ const loadingEl = card.querySelector('.ct-card-thumb-loading');
101
+ const placeholder = card.querySelector('.landing-template-thumb-placeholder');
102
+ const iframe = card.querySelector('.ct-thumb-iframe');
103
+ if (!iframe) return;
104
+ try {
105
+ const data = await api(`/api/content-templates/public/${id}/preview`);
106
+ if (data.file_type === 'markdown') {
107
+ iframe.srcdoc = `<pre style="padding:24px;font-size:14px;white-space:pre-wrap;word-break:break-word;margin:0">${escapeHtml(data.content)}</pre>`;
108
+ } else {
109
+ iframe.srcdoc = data.content;
110
+ }
111
+ iframe.onload = () => {
112
+ if (loadingEl) loadingEl.remove();
113
+ if (placeholder) placeholder.style.display = 'none';
114
+ };
115
+ } catch {
116
+ if (loadingEl) loadingEl.remove();
117
+ }
118
+ }
119
+
120
+ // 模板预览弹窗
121
+ const previewModal = el.querySelector('#template-preview-modal');
122
+ const previewTitle = el.querySelector('#template-preview-title');
123
+ const previewMeta = el.querySelector('#template-preview-meta');
124
+ const previewIframe = el.querySelector('#template-preview-iframe');
125
+
126
+ el.querySelector('#template-preview-close').addEventListener('click', () => { previewModal.hidden = true; });
127
+ previewModal.addEventListener('click', (e) => { if (e.target === previewModal) previewModal.hidden = true; });
128
+
129
+ async function openTemplatePreview(id) {
130
+ previewTitle.textContent = '加载中…';
131
+ previewMeta.innerHTML = '';
132
+ previewIframe.srcdoc = '';
133
+ previewModal.hidden = false;
134
+ try {
135
+ const data = await api(`/api/content-templates/public/${id}/preview`);
136
+ previewTitle.textContent = data.title;
137
+ previewMeta.innerHTML = `<span class="ct-badge ct-badge-type">${(data.file_type || 'html').toUpperCase()}</span>`;
138
+ if (data.file_type === 'markdown') {
139
+ previewIframe.srcdoc = `<pre style="padding:24px;font-size:14px;white-space:pre-wrap;">${escapeHtml(data.content)}</pre>`;
140
+ } else {
141
+ previewIframe.srcdoc = data.content;
142
+ }
143
+ } catch {
144
+ previewTitle.textContent = '加载失败';
145
+ }
146
+ }
147
+
148
+ // 初始加载模板(仅在模板展示区块存在时)
149
+ if (grid && emptyEl) loadTemplates();
150
+ }
151
+
152
+ function escapeHtml(str) {
153
+ const div = document.createElement('div');
154
+ div.textContent = str;
155
+ return div.innerHTML;
156
+ }
157
+
158
+ export { renderLanding };
@@ -0,0 +1,175 @@
1
+ // 登录/注册页:全屏认证页面,tab 切换登录和注册
2
+
3
+ import { api } from '../api.js';
4
+ import { toast } from '../components/toast.js';
5
+ import { state, navigate } from '../app.js';
6
+
7
+ function renderLogin(container, openTab) {
8
+ if (state.currentUser) { navigate('/'); return; }
9
+ const tmpl = document.getElementById('login-template');
10
+ container.innerHTML = '';
11
+ container.appendChild(tmpl.content.cloneNode(true));
12
+
13
+ // 检查注册是否开放,动态隐藏注册 tab 和表单
14
+ const registerTab = container.querySelector('.auth-tab[data-tab="register"]');
15
+ const registerForm = container.querySelector('#register-form');
16
+
17
+ api('/api/auth/registration-status').then(data => {
18
+ if (!data.enabled && registerTab) registerTab.hidden = true;
19
+ }).catch(() => {});
20
+
21
+ const tabs = container.querySelectorAll('.auth-tab');
22
+ const loginForm = container.querySelector('#login-form');
23
+
24
+ function switchTab(tab) {
25
+ tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
26
+ loginForm.classList.toggle('active', tab === 'login');
27
+ registerForm.classList.toggle('active', tab === 'register');
28
+ }
29
+
30
+ tabs.forEach(tab => {
31
+ tab.addEventListener('click', () => {
32
+ switchTab(tab.dataset.tab);
33
+ location.hash = '/' + tab.dataset.tab;
34
+ });
35
+ });
36
+
37
+ const initialTab = openTab || (location.hash === '#/register' ? 'register' : 'login');
38
+ switchTab(initialTab);
39
+
40
+ // 登录 — 统一入口,自动识别用户名或邮箱
41
+ const loginError = container.querySelector('#login-error');
42
+ const loginSubmit = container.querySelector('#login-submit');
43
+ loginForm.addEventListener('submit', async (e) => {
44
+ e.preventDefault();
45
+ const account = container.querySelector('#login-username').value.trim();
46
+ const password = container.querySelector('#login-password').value;
47
+ if (!account || !password) return;
48
+ loginError.hidden = true;
49
+ loginSubmit.disabled = true;
50
+ const origText = loginSubmit.textContent;
51
+ loginSubmit.textContent = '登录中…';
52
+ try {
53
+ const data = await api('/api/auth/login', {
54
+ method: 'POST',
55
+ body: { account, password }
56
+ });
57
+ state.currentUser = data;
58
+ toast('登录成功');
59
+ navigate('/');
60
+ } catch (e) {
61
+ loginError.textContent = e.message || '登录失败';
62
+ loginError.hidden = false;
63
+ } finally {
64
+ loginSubmit.disabled = false;
65
+ loginSubmit.textContent = origText;
66
+ }
67
+ });
68
+
69
+ // 注册 — 邮箱必填,验证码校验
70
+ const registerError = container.querySelector('#register-error');
71
+ const registerSubmit = container.querySelector('#register-submit');
72
+ const emailInput = container.querySelector('#register-email');
73
+ const usernameInput = container.querySelector('#register-username');
74
+ const usernameHint = container.querySelector('#register-username-hint');
75
+ const codeInput = container.querySelector('#register-code');
76
+ const sendCodeBtn = container.querySelector('#btn-send-code');
77
+ const codeTip = container.querySelector('#register-code-tip');
78
+ let codeTimer = null;
79
+ let tipTimer = null;
80
+
81
+ // 用户名实时校验
82
+ usernameInput.addEventListener('input', () => {
83
+ const val = usernameInput.value.trim();
84
+ if (!val) { usernameHint.hidden = true; usernameHint.textContent = ''; return; }
85
+ if (/[^a-zA-Z0-9_]/.test(val)) {
86
+ usernameHint.textContent = '用户名只能包含字母、数字和下划线';
87
+ usernameHint.hidden = false;
88
+ } else if (val.length < 2) {
89
+ usernameHint.textContent = '用户名至少 2 个字符';
90
+ usernameHint.hidden = false;
91
+ } else {
92
+ usernameHint.hidden = true;
93
+ usernameHint.textContent = '';
94
+ }
95
+ });
96
+
97
+ // 发送验证码
98
+ sendCodeBtn.addEventListener('click', async () => {
99
+ const email = emailInput.value.trim();
100
+ if (!email) { registerError.textContent = '请先填写邮箱'; registerError.hidden = false; return; }
101
+ registerError.hidden = true;
102
+ sendCodeBtn.disabled = true;
103
+ try {
104
+ await api('/api/auth/send-register-code', { method: 'POST', body: { email } });
105
+ toast('验证码已发送');
106
+ let remain = 60;
107
+ sendCodeBtn.textContent = remain + 's';
108
+ codeTimer = setInterval(() => {
109
+ remain--;
110
+ if (remain <= 0) {
111
+ clearInterval(codeTimer);
112
+ clearInterval(tipTimer);
113
+ sendCodeBtn.disabled = false;
114
+ sendCodeBtn.textContent = '发送验证码';
115
+ codeTip.hidden = true;
116
+ } else {
117
+ sendCodeBtn.textContent = remain + 's';
118
+ }
119
+ }, 1000);
120
+ // 剩余 30s 时开始轮转提示
121
+ setTimeout(() => {
122
+ if (remain <= 0) return;
123
+ const tips = ['还没收到?检查一下垃圾邮件', '邮件可能在垃圾箱里,去看看吧', '仍未收到?稍等片刻再查看'];
124
+ let tipIdx = 0;
125
+ function showTip() {
126
+ codeTip.textContent = tips[tipIdx % tips.length];
127
+ codeTip.hidden = false;
128
+ tipIdx++;
129
+ }
130
+ showTip();
131
+ tipTimer = setInterval(showTip, 5000);
132
+ }, 30000);
133
+ } catch (e) {
134
+ registerError.textContent = e.message || '发送失败';
135
+ registerError.hidden = false;
136
+ sendCodeBtn.disabled = false;
137
+ }
138
+ });
139
+
140
+ registerForm.addEventListener('submit', async (e) => {
141
+ e.preventDefault();
142
+ const email = emailInput.value.trim();
143
+ const code = codeInput.value.trim();
144
+ const username = usernameInput.value.trim();
145
+ const password = container.querySelector('#register-password').value;
146
+ const confirmPassword = container.querySelector('#register-confirm').value;
147
+ if (!email) { registerError.textContent = '请填写邮箱'; registerError.hidden = false; return; }
148
+ if (!code) { registerError.textContent = '请填写验证码'; registerError.hidden = false; return; }
149
+ if (username && !/^[a-zA-Z0-9_]{2,30}$/.test(username)) { registerError.textContent = '用户名只能包含字母、数字和下划线,2-30 位'; registerError.hidden = false; return; }
150
+ if (!password || !confirmPassword) return;
151
+ registerError.hidden = true;
152
+ registerSubmit.disabled = true;
153
+ const origText = registerSubmit.textContent;
154
+ registerSubmit.textContent = '注册中…';
155
+ try {
156
+ const body = { email, code, password, confirmPassword };
157
+ if (username) body.username = username;
158
+ const data = await api('/api/auth/register', {
159
+ method: 'POST',
160
+ body
161
+ });
162
+ state.currentUser = data;
163
+ toast('注册成功');
164
+ navigate('/');
165
+ } catch (e) {
166
+ registerError.textContent = e.message || '注册失败';
167
+ registerError.hidden = false;
168
+ } finally {
169
+ registerSubmit.disabled = false;
170
+ registerSubmit.textContent = origText;
171
+ }
172
+ });
173
+ }
174
+
175
+ export { renderLogin };