edgepress 2026.10.13 → 2026.10.14

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.
@@ -0,0 +1,396 @@
1
+ const $ = id => document.getElementById(id);
2
+ const state = { path: '', page: null, revision: '', selected: null, dirty: false };
3
+ const labels = {
4
+ hero: '横幅 / Hero', section: '分区', heading: '标题', text: '文字', image: '图片',
5
+ quote: '引用', cta: '操作按钮', 'link-list': '链接列表', 'feature-grid': '功能卡片',
6
+ faq: '问答', code: '代码', notice: '提示', 'data-table': '数据表',
7
+ 'post-list': '文章列表', 'latest-posts': '最新文章', document: '文档',
8
+ 'media-text': '图文', 'media-viewer': '媒体浏览器', 'display-text': '展示文字',
9
+ list: '列表', steps: '步骤', 'link-directory': '链接目录', oembed: '外部嵌入',
10
+ service: '第三方服务', 'local-tool': '本地工具', 'privacy-services': '服务隐私',
11
+ 'privacy-consent': '隐私同意', 'privacy-controller': '隐私设置',
12
+ captcha: '验证码', 'ad-slot': '广告位'
13
+ };
14
+ const templates = {
15
+ heading: { type: 'heading', level: 2, text: '新标题' },
16
+ text: { type: 'text', text: '在这里输入文字' },
17
+ hero: { type: 'hero', title: '页面标题', text: '介绍一下这个页面' },
18
+ section: { type: 'section', title: '新分区', blocks: [{ type: 'text', text: '分区内容' }] },
19
+ image: { type: 'image', src: '/images/example.png', alt: '图片描述' },
20
+ quote: { type: 'quote', text: '引用内容' },
21
+ cta: { type: 'cta', label: '了解更多', url: '/quick-start/' },
22
+ 'link-list': { type: 'link-list', title: '相关链接', items: [{ label: '查看内容', url: '/quick-start/' }] },
23
+ code: { type: 'code', language: 'text', code: '示例代码' },
24
+ notice: { type: 'notice', title: '提示', text: '提示内容' },
25
+ 'post-list': { type: 'post-list', title: '文章列表', count: 3 },
26
+ 'latest-posts': { type: 'latest-posts', title: '最新文章', count: 3 },
27
+ document: { type: 'document', title: '文档预览', src: '/documents/sample.pdf' },
28
+ 'feature-grid': { type: 'feature-grid', title: '功能列表', items: [{ title: '功能', text: '功能介绍', icon: 'check' }] },
29
+ faq: { type: 'faq', title: '常见问题', items: [{ question: '问题', answer: '答案' }] }
30
+ };
31
+ const suggestions = {
32
+ hero: { eyebrow: '', cta: { label: '了解更多', url: '/' }, highlights: ['亮点'] },
33
+ image: { title: '', caption: '', width: 800, height: 450 },
34
+ heading: { id: '' }, text: { paragraphs: ['新段落'] },
35
+ section: { tone: 'soft', id: '' }, quote: { author: '' },
36
+ 'media-text': { title: '', text: '', src: '', alt: '', placement: 'left' },
37
+ code: { title: '' }, document: { caption: '' }
38
+ };
39
+
40
+ function el(tag, text, cssClass) {
41
+ const node = document.createElement(tag);
42
+ if (text !== undefined && text !== null) node.textContent = String(text);
43
+ if (cssClass) node.className = cssClass;
44
+ return node;
45
+ }
46
+ function button(label, onClick, cssClass) {
47
+ const node = el('button', label, cssClass);
48
+ node.type = 'button';
49
+ node.addEventListener('click', onClick);
50
+ return node;
51
+ }
52
+ function info(message, error = false, saved = false) {
53
+ $('status').textContent = message;
54
+ $('status').className = error ? 'error' : saved ? 'saved' : '';
55
+ }
56
+ function changed() {
57
+ state.dirty = true;
58
+ info('有尚未保存的更改');
59
+ }
60
+ function guarded(next) {
61
+ if (!state.dirty || window.confirm('有未保存的更改,确定放弃吗?')) next();
62
+ }
63
+ function selectedBlock() {
64
+ const s = state.selected;
65
+ return s && state.page?.blocks[s.row]?.cells[s.cell]?.[s.block];
66
+ }
67
+ function selection(row, cell, block) {
68
+ state.selected = { row, cell, block };
69
+ repaint();
70
+ }
71
+ function move(list, index, difference) {
72
+ const next = index + difference;
73
+ if (next < 0 || next >= list.length) return;
74
+ [list[index], list[next]] = [list[next], list[index]];
75
+ changed();
76
+ }
77
+ async function request(path, options) {
78
+ const response = await fetch(path, { cache: 'no-store', ...options });
79
+ const data = await response.json();
80
+ if (!response.ok) throw new Error(data.error || 'HTTP ' + response.status);
81
+ return data;
82
+ }
83
+ async function reloadPages() {
84
+ const data = await request('/api/pages');
85
+ const picker = $('page-picker');
86
+ picker.replaceChildren();
87
+ data.pages.forEach(path => {
88
+ const option = el('option', path); option.value = path; picker.append(option);
89
+ });
90
+ if (!data.pages.length) {
91
+ info('没有找到 JSON 页面。请先在 content/pages/ 下创建页面。', true);
92
+ return;
93
+ }
94
+ if (state.path && data.pages.includes(state.path)) picker.value = state.path;
95
+ await loadPage(picker.value);
96
+ }
97
+ async function loadPage(path) {
98
+ if (!path) return;
99
+ try {
100
+ const data = await request('/api/page?path=' + encodeURIComponent(path));
101
+ state.path = path;
102
+ state.page = data.page;
103
+ state.revision = data.revision;
104
+ state.selected = null;
105
+ state.dirty = false;
106
+ $('page-picker').value = path;
107
+ $('title').value = data.page.title || '';
108
+ $('description').value = data.page.description || '';
109
+ $('page-name').textContent = data.page.title || path;
110
+ $('page-path').textContent = path + ' · ' + (data.page.lang || '');
111
+ repaint();
112
+ info('已载入 ' + path, false, true);
113
+ } catch (error) { info('无法载入页面:' + error.message, true); }
114
+ }
115
+ function snippet(block) {
116
+ const value = block.title || block.text || block.code || block.src || block.label || block.type;
117
+ return String(value).replace(/\s+/g, ' ').slice(0, 44);
118
+ }
119
+ function repaint() {
120
+ if (!state.page) return;
121
+ drawOutline();
122
+ drawCanvas();
123
+ drawInspector();
124
+ }
125
+ function drawOutline() {
126
+ const nav = $('outline'); nav.replaceChildren();
127
+ state.page.blocks.forEach((row, rowIndex) => {
128
+ const group = el('div', null, 'outline-row');
129
+ group.append(el('strong', '第 ' + (rowIndex + 1) + ' 行 · ' + row.columns + ' 栏'));
130
+ row.cells.forEach((cell, cellIndex) => {
131
+ const section = el('div', null, 'outline-cell');
132
+ section.append(el('small', '栏 ' + (cellIndex + 1)));
133
+ cell.forEach((block, blockIndex) => {
134
+ const item = button((labels[block.type] || block.type) + ' · ' + snippet(block), () => selection(rowIndex, cellIndex, blockIndex), 'outline-button');
135
+ if (state.selected?.row === rowIndex && state.selected?.cell === cellIndex && state.selected?.block === blockIndex) item.setAttribute('aria-current', 'true');
136
+ section.append(item);
137
+ });
138
+ group.append(section);
139
+ });
140
+ nav.append(group);
141
+ });
142
+ }
143
+ function addBlock(rowIndex, cellIndex, type) {
144
+ const cell = state.page.blocks[rowIndex].cells[cellIndex];
145
+ cell.push(structuredClone(templates[type]));
146
+ changed();
147
+ selection(rowIndex, cellIndex, cell.length - 1);
148
+ }
149
+ function drawCanvas() {
150
+ const canvas = $('canvas'); canvas.replaceChildren();
151
+ state.page.blocks.forEach((row, rowIndex) => {
152
+ const wrap = el('section', null, 'canvas-row');
153
+ const toolbar = el('div', null, 'row-actions');
154
+ const title = el('strong', '行 ' + (rowIndex + 1));
155
+ const operations = el('div', null, 'row-actions');
156
+ const count = el('select'); count.setAttribute('aria-label', '第 ' + (rowIndex + 1) + ' 行的栏数');
157
+ for (let n = 1; n <= 4; n++) {
158
+ const opt = el('option', n + ' 栏'); opt.value = String(n);
159
+ count.append(opt);
160
+ }
161
+ count.value = String(row.columns);
162
+ count.addEventListener('change', () => {
163
+ const next = Number(count.value);
164
+ // Reducing columns reflows all existing elements into the last kept cell.
165
+ // Never discard content merely because the editor layout changes.
166
+ if (next < row.columns) {
167
+ for (const extra of row.cells.slice(next)) row.cells[next - 1].push(...extra);
168
+ }
169
+ while (row.cells.length < next) row.cells.push([{ type: 'text', text: '新栏内容' }]);
170
+ row.cells.length = next;
171
+ row.columns = next;
172
+ state.selected = null;
173
+ changed(); repaint();
174
+ });
175
+ operations.append(count,
176
+ button('↑', () => { move(state.page.blocks, rowIndex, -1); state.selected = null; repaint(); }),
177
+ button('↓', () => { move(state.page.blocks, rowIndex, 1); state.selected = null; repaint(); }),
178
+ button('删除行', () => {
179
+ if (state.page.blocks.length === 1) return info('页面至少需要一行。', true);
180
+ if (!window.confirm('确定删除整行及其所有元素?')) return;
181
+ state.page.blocks.splice(rowIndex, 1); state.selected = null; changed(); repaint();
182
+ }, 'danger')
183
+ );
184
+ toolbar.append(title, operations); wrap.append(toolbar);
185
+ const cells = el('div', null, 'cells columns-' + row.columns);
186
+ row.cells.forEach((blocks, cellIndex) => {
187
+ const cell = el('div', null, 'canvas-cell');
188
+ const tools = el('div', null, 'cell-actions');
189
+ tools.append(el('strong', '栏 ' + (cellIndex + 1)));
190
+ const select = el('select'); select.setAttribute('aria-label', '选择要添加的元素类型');
191
+ for (const [key, value] of Object.entries(templates)) {
192
+ const option = el('option', labels[key] || key); option.value = key; select.append(option);
193
+ }
194
+ tools.append(select, button('+ 添加', () => addBlock(rowIndex, cellIndex, select.value)));
195
+ cell.append(tools);
196
+ blocks.forEach((block, blockIndex) => {
197
+ const selected = state.selected?.row === rowIndex && state.selected?.cell === cellIndex && state.selected?.block === blockIndex;
198
+ const card = el('article', null, 'element' + (selected ? ' active' : ''));
199
+ const head = el('div', null, 'element-head');
200
+ const choose = button(labels[block.type] || block.type, () => selection(rowIndex, cellIndex, blockIndex), 'pick');
201
+ choose.setAttribute('aria-pressed', String(Boolean(selected)));
202
+ const operations = el('div', null, 'element-actions');
203
+ operations.append(
204
+ button('↑', () => { move(blocks, blockIndex, -1); selection(rowIndex, cellIndex, Math.max(0, blockIndex - 1)); }),
205
+ button('↓', () => { move(blocks, blockIndex, 1); selection(rowIndex, cellIndex, Math.min(blocks.length - 1, blockIndex + 1)); }),
206
+ button('×', () => {
207
+ if (blocks.length === 1) return info('每栏至少保留一个元素。可直接修改这个元素。', true);
208
+ blocks.splice(blockIndex, 1); state.selected = null; changed(); repaint();
209
+ }, 'danger')
210
+ );
211
+ head.append(choose, operations); card.append(head, preview(block));
212
+ card.addEventListener('click', event => { if (!event.target.closest('button')) selection(rowIndex, cellIndex, blockIndex); });
213
+ cell.append(card);
214
+ });
215
+ cells.append(cell);
216
+ });
217
+ wrap.append(cells); canvas.append(wrap);
218
+ });
219
+ }
220
+ function preview(block) {
221
+ const box = el('div', null, 'element-preview');
222
+ const heading = block.title || (block.type === 'heading' ? block.text : '');
223
+ if (heading) box.append(el(block.type === 'hero' ? 'h2' : 'h3', heading));
224
+ if (block.eyebrow) box.prepend(el('small', block.eyebrow));
225
+ if (block.type === 'image' || block.type === 'document' || block.type === 'media-viewer') {
226
+ box.append(el('div', (block.type === 'image' ? '▧ 图片 · ' : '▤ 资源 · ') + (block.src || block.alt || '选择资源'), 'placeholder'));
227
+ } else if (block.type === 'quote') {
228
+ box.append(el('blockquote', block.text || '引用'));
229
+ } else if (block.type === 'code') {
230
+ const code = el('code', block.code || '代码');
231
+ const pre = el('pre'); pre.append(code); box.append(pre);
232
+ } else if (block.type === 'link-list' && Array.isArray(block.items)) {
233
+ for (const item of block.items.slice(0, 6)) box.append(el('p', '↗ ' + (item.label || item.title || '链接')));
234
+ } else if (block.type === 'section' && Array.isArray(block.blocks)) {
235
+ for (const nested of block.blocks.slice(0, 4)) box.append(el('p', '└ ' + (nested.title || nested.text || nested.type)));
236
+ } else if (block.type === 'feature-grid' && Array.isArray(block.items)) {
237
+ for (const item of block.items.slice(0, 4)) box.append(el('p', '▪ ' + (item.title || item.text || '功能')));
238
+ } else if (block.type === 'faq' && Array.isArray(block.items)) {
239
+ for (const item of block.items.slice(0, 4)) box.append(el('p', '❔ ' + (item.question || item.title || '问题')));
240
+ } else if (block.type !== 'heading' && block.type !== 'cta' && block.text) {
241
+ box.append(el('p', block.text));
242
+ }
243
+ if (Array.isArray(block.paragraphs)) box.append(el('p', block.paragraphs.slice(0, 3).join('\n')));
244
+ if (block.type === 'cta') box.append(el('p', '↗ ' + (block.label || block.cta?.label || block.url || '操作按钮')));
245
+ if (!box.childNodes.length) box.append(el('p', '点击上方元素名称,编辑 ' + (labels[block.type] || block.type), 'muted'));
246
+ return box;
247
+ }
248
+ function drawInspector() {
249
+ const fields = $('fields'); fields.replaceChildren();
250
+ const block = selectedBlock();
251
+ if (!block) {
252
+ $('selected-label').textContent = '尚未选择';
253
+ fields.append(el('p', '请在画布或页面结构中选择元素。', 'empty'));
254
+ return;
255
+ }
256
+ $('selected-label').textContent = (state.selected.row + 1) + ' 行 · ' + (state.selected.cell + 1) + ' 栏';
257
+ fields.append(el('h3', labels[block.type] || block.type));
258
+ const form = el('form'); form.addEventListener('submit', event => event.preventDefault());
259
+ objectFields(form, block, 0, true);
260
+ fields.append(form);
261
+ }
262
+ function fieldLabel(key, note) {
263
+ const label = el('label', key, 'field');
264
+ if (note) label.append(el('small', note, 'type-hint'));
265
+ return label;
266
+ }
267
+ function replaceValue(target, key, value) {
268
+ target[key] = value; changed(); drawOutline(); drawCanvas();
269
+ }
270
+ function objectFields(container, obj, depth, root = false) {
271
+ for (const [key, value] of Object.entries(obj)) {
272
+ if (root && key === 'type') continue;
273
+ const field = fieldLabel(key);
274
+ if (typeof value === 'string') {
275
+ const control = key === 'text' || key === 'code' || key === 'fallback' || value.length > 130 ? el('textarea') : el('input');
276
+ if (control.tagName === 'INPUT') control.type = 'text';
277
+ control.value = value;
278
+ control.setAttribute('aria-label', key);
279
+ control.addEventListener('input', () => replaceValue(obj, key, control.value));
280
+ field.append(control);
281
+ } else if (typeof value === 'number') {
282
+ const control = el('input'); control.type = 'number'; control.value = value;
283
+ control.addEventListener('change', () => { if (Number.isFinite(control.valueAsNumber)) replaceValue(obj, key, control.valueAsNumber); });
284
+ field.append(control);
285
+ } else if (typeof value === 'boolean') {
286
+ field.classList.add('checkbox');
287
+ const control = el('input'); control.type = 'checkbox'; control.checked = value;
288
+ control.addEventListener('change', () => replaceValue(obj, key, control.checked));
289
+ field.append(control);
290
+ } else if (Array.isArray(value)) {
291
+ field.append(arrayEditor(value, obj, key, depth));
292
+ } else if (value && typeof value === 'object' && depth < 5) {
293
+ const group = el('div', null, 'object-editor');
294
+ objectFields(group, value, depth + 1);
295
+ field.append(group);
296
+ } else {
297
+ const control = el('textarea'); control.value = JSON.stringify(value, null, 2);
298
+ control.addEventListener('change', () => {
299
+ try { replaceValue(obj, key, JSON.parse(control.value)); }
300
+ catch { info('字段 ' + key + ' 不是有效 JSON', true); }
301
+ });
302
+ field.append(control);
303
+ }
304
+ container.append(field);
305
+ }
306
+ if (root) {
307
+ const allowed = suggestions[obj.type] || {};
308
+ const missing = Object.keys(allowed).filter(key => !(key in obj));
309
+ if (missing.length) {
310
+ const chooser = el('select'); chooser.setAttribute('aria-label', '添加可选字段');
311
+ missing.forEach(key => { const option = el('option', key); option.value = key; chooser.append(option); });
312
+ container.append(chooser, button('+ 添加属性', () => {
313
+ const key = chooser.value;
314
+ obj[key] = structuredClone(allowed[key]);
315
+ changed(); repaint();
316
+ }));
317
+ }
318
+ }
319
+ }
320
+ function arrayEditor(value, owner, key, depth) {
321
+ const box = el('div', null, 'array-editor');
322
+ if (value.every(v => typeof v === 'string')) {
323
+ const control = el('textarea');
324
+ control.value = value.join('\n');
325
+ control.setAttribute('aria-label', key + ' 每行一项');
326
+ control.addEventListener('input', () => replaceValue(owner, key, control.value ? control.value.split('\n') : []));
327
+ box.append(el('small', '每行一项', 'type-hint'), control);
328
+ } else if (depth < 4 && value.every(v => v && typeof v === 'object' && !Array.isArray(v))) {
329
+ for (const [index, item] of value.entries()) {
330
+ const card = el('div', null, 'array-item');
331
+ const head = el('div', null, 'item-head');
332
+ head.append(el('strong', '项目 ' + (index + 1)), button('移除', () => {
333
+ if (value.length <= 1 && key === 'blocks') return info('分区至少保留一个子元素', true);
334
+ value.splice(index, 1); changed(); repaint();
335
+ }, 'danger'));
336
+ card.append(head);
337
+ objectFields(card, item, depth + 1, false);
338
+ box.append(card);
339
+ }
340
+ box.append(button('+ 添加项目', () => {
341
+ const sample = value.length ? structuredClone(value[0]) : key === 'blocks' ? structuredClone(templates.text) : { label: '', url: '/' };
342
+ for (const [name, v] of Object.entries(sample)) {
343
+ if (name === 'type') continue;
344
+ if (typeof v === 'string') sample[name] = '';
345
+ else if (typeof v === 'number') sample[name] = 1;
346
+ }
347
+ value.push(sample); changed(); repaint();
348
+ }));
349
+ } else {
350
+ const control = el('textarea'); control.value = JSON.stringify(value, null, 2);
351
+ control.setAttribute('aria-label', key + ' 高级 JSON');
352
+ control.addEventListener('change', () => {
353
+ try { const next = JSON.parse(control.value); if (!Array.isArray(next)) throw Error(); replaceValue(owner, key, next); }
354
+ catch { info('字段 ' + key + ' 必须是 JSON 数组', true); }
355
+ });
356
+ box.append(el('small', '复杂嵌套数组 · 高级编辑', 'type-hint'), control);
357
+ }
358
+ return box;
359
+ }
360
+ async function save() {
361
+ if (!state.page || !state.dirty) return info('没有需要保存的更改。');
362
+ $('save').disabled = true;
363
+ try {
364
+ const result = await request('/api/page?path=' + encodeURIComponent(state.path), {
365
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
366
+ body: JSON.stringify({ page: state.page, revision: state.revision })
367
+ });
368
+ state.revision = result.revision; state.dirty = false;
369
+ info('已安全保存到 ' + state.path + '。运行 npm run build 检查最终网页。', false, true);
370
+ } catch (error) { info('保存失败:' + error.message, true); }
371
+ finally { $('save').disabled = false; }
372
+ }
373
+
374
+ $('save').addEventListener('click', save);
375
+ $('reload').addEventListener('click', () => guarded(() => loadPage(state.path)));
376
+ $('page-picker').addEventListener('change', event => {
377
+ const path = event.target.value;
378
+ guarded(() => loadPage(path));
379
+ if (state.dirty) $('page-picker').value = state.path;
380
+ });
381
+ $('add-row').addEventListener('click', () => {
382
+ if (!state.page) return;
383
+ if (state.page.blocks.length >= 100) return info('最多允许 100 行', true);
384
+ state.page.blocks.push({ columns: 1, cells: [[structuredClone(templates.text)]] });
385
+ changed(); selection(state.page.blocks.length - 1, 0, 0);
386
+ });
387
+ for (const key of ['title', 'description']) {
388
+ $(key).addEventListener('input', () => {
389
+ if (!state.page) return;
390
+ state.page[key] = $(key).value;
391
+ $('page-name').textContent = state.page.title || '未命名页面';
392
+ changed();
393
+ });
394
+ }
395
+ window.addEventListener('beforeunload', event => { if (state.dirty) event.preventDefault(); });
396
+ reloadPages().catch(error => info('无法启动编辑器:' + error.message, true));
@@ -0,0 +1,86 @@
1
+ // Shares the module scope of page-editor-client.js. No third-party script or eval.
2
+ async function initializeBlockCatalog() {
3
+ const definition = await request('/api/block-catalog');
4
+ for (const [type, entry] of Object.entries(definition.blocks)) {
5
+ labels[type] = entry.label + (entry.requiresSetup ? ' · 需配置' : '');
6
+ templates[type] = structuredClone(entry.template);
7
+ }
8
+ repaint();
9
+ }
10
+
11
+ async function showNewPageDialog() {
12
+ const { locales, defaultLocale } = await request('/api/locales');
13
+ const dialog = el('dialog');
14
+ dialog.className = 'new-page-dialog';
15
+ const form = el('form');
16
+ form.setAttribute('aria-label', '新建页面');
17
+ const heading = el('h2', '新建 Pages 页面');
18
+ const hint = el('p', '填写页面标题和语言,选择是否手动指定 URL 路径。创建后可继续可视化编辑。', 'help');
19
+ const labelled = (caption, control) => {
20
+ const label = el('label', caption, 'field');
21
+ label.append(control);
22
+ return label;
23
+ };
24
+ const title = el('input');
25
+ title.type = 'text';
26
+ title.required = true;
27
+ title.maxLength = 160;
28
+ title.placeholder = '例如:关于我们';
29
+ title.id = 'create-title';
30
+ const slug = el('input');
31
+ slug.type = 'text';
32
+ slug.maxLength = 100;
33
+ slug.placeholder = '留空时根据标题生成';
34
+ slug.id = 'create-slug';
35
+ const locale = el('select');
36
+ locale.id = 'create-lang';
37
+ for (const value of locales) {
38
+ const option = el('option', value);
39
+ option.value = value;
40
+ locale.append(option);
41
+ }
42
+ locale.value = defaultLocale;
43
+ const message = el('p', '', 'new-page-error');
44
+ message.setAttribute('role', 'alert');
45
+ const actions = el('div', null, 'actions');
46
+ const cancel = button('取消', () => dialog.close());
47
+ const submit = el('button', '创建并编辑', 'primary');
48
+ submit.type = 'submit';
49
+ actions.append(cancel, submit);
50
+ form.append(heading, hint, labelled('页面标题', title), labelled('URL 路径名称(可选)', slug), labelled('页面语言', locale), actions, message);
51
+ dialog.append(form);
52
+ document.body.append(dialog);
53
+ dialog.addEventListener('close', () => dialog.remove());
54
+ form.addEventListener('submit', async event => {
55
+ event.preventDefault();
56
+ submit.disabled = true;
57
+ message.textContent = '';
58
+ try {
59
+ const created = await request('/api/pages', {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({
63
+ title: title.value.trim(), slug: slug.value.trim() || undefined,
64
+ lang: locale.value
65
+ })
66
+ });
67
+ dialog.close();
68
+ state.dirty = false;
69
+ state.path = created.path;
70
+ await reloadPages();
71
+ info('新页面已创建:' + created.path + '。请继续选择和编辑元素。', false, true);
72
+ } catch (error) {
73
+ message.textContent = error.message;
74
+ info('创建页面失败:' + error.message, true);
75
+ } finally {
76
+ submit.disabled = false;
77
+ }
78
+ });
79
+ dialog.showModal();
80
+ title.focus();
81
+ }
82
+
83
+ $('new-page').addEventListener('click', () => guarded(() => {
84
+ showNewPageDialog().catch(error => info('无法打开新建页面:' + error.message, true));
85
+ }));
86
+ initializeBlockCatalog().catch(error => info('无法载入全部元素模板:' + error.message, true));
@@ -0,0 +1 @@
1
+ :root{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#263343;background:#f1f5f8;line-height:1.5}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;flex-direction:column}button,input,select,textarea{font:inherit}button{cursor:pointer;border:1px solid #cdd8e2;background:white;color:#244056;padding:.45rem .75rem;border-radius:.55rem;font-weight:600}button:hover{background:#eaf3fa}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #5fa9ee;outline-offset:2px}button:disabled{cursor:not-allowed;opacity:.45}button.primary{background:#1d5e91;border-color:#1d5e91;color:white}button.primary:hover{background:#154e7d}button.danger{color:#a33838}.subtle{background:transparent}.topbar{padding:.8rem 1.4rem;background:#fff;border-bottom:1px solid #dbe3e9;display:flex;gap:1rem;align-items:center;justify-content:space-between;flex-wrap:wrap}.brand{display:flex;align-items:center;gap:.6rem}.brand-icon{display:grid;place-items:center;background:#176397;color:white;border-radius:12px;width:44px;height:44px;font-size:1.8rem}.brand strong{font-size:1.1rem;display:block}.brand small{color:#536477;display:block}.actions{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.actions select{min-width:230px;max-width:360px}.workspace{flex:1;min-height:calc(100vh - 144px);display:grid;grid-template-columns:minmax(210px,250px) minmax(360px,1fr) minmax(275px,335px);gap:1px;background:#dbe3e9}.panel{background:#fff;min-width:0;padding:1rem;overflow:auto}.side,.inspector{max-height:calc(100vh - 135px)}.center{background:#f8fafc}.panel-header{display:flex;align-items:center;justify-content:space-between;gap:.7rem;margin-bottom:.6rem}.panel-header h1,.panel-header h2{margin:0}.panel-header h1{font-size:1.18rem}.panel-header h2{font-size:1rem}.muted,.help{color:#657687;font-size:.84rem}.help{margin:.5rem 0 1rem}.note{margin:1rem auto;max-width:780px}.page-meta{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin:1.1rem 0}.page-meta label{font-size:.83rem;color:#566778}input,textarea,select{background:#fff;border:1px solid #bfcbd7;border-radius:.45rem;padding:.43rem .55rem;color:#263343;max-width:100%}input[type="text"],input[type="number"],textarea,.page-meta input{width:100%;display:block;margin-top:.3rem}textarea{min-height:80px;resize:vertical}.outline-row{padding:.65rem .3rem .6rem;border-bottom:1px solid #e8eef2}.outline-row strong{font-size:.89rem}.outline-cell{margin:.4rem 0 .45rem .75rem;border-left:2px solid #e1eaf1;padding-left:.7rem}.outline-cell small{display:block;color:#76899b}.outline-button{display:block;width:100%;text-align:left;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:.26rem}.outline-button[aria-current="true"]{border-color:#2b7db7;background:#ebf6ff}.canvas-wrap{max-width:900px;margin:auto}.canvas{background:#fff;min-height:350px;padding:1.5rem;box-shadow:0 8px 30px #253f5612;border:1px solid #e0e9ef;border-radius:.8rem}.canvas-row{margin:0 0 1.4rem;border:1px dashed #cbd9e4;padding:.7rem;border-radius:.65rem}.row-actions,.cell-actions,.element-actions{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}.row-actions{justify-content:space-between;margin-bottom:.7rem;color:#627487}.row-actions button,.cell-actions button,.element-actions button{font-size:.78rem;padding:.25rem .45rem}.row-actions label{font-size:.78rem;display:flex;align-items:center;gap:.35rem}.row-actions select{padding:.15rem}.cells{display:grid;gap:.8rem}.columns-1{grid-template-columns:minmax(0,1fr)}.columns-2{grid-template-columns:repeat(2,minmax(0,1fr))}.columns-3{grid-template-columns:repeat(3,minmax(0,1fr))}.columns-4{grid-template-columns:repeat(4,minmax(0,1fr))}.canvas-cell{background:#f8fbff;border-radius:.4rem;border:1px solid #e1eaf2;padding:.6rem;min-width:0}.cell-actions{justify-content:space-between;margin-bottom:.6rem}.cell-actions select{font-size:.8rem;min-width:0;max-width:150px}.element{border:2px solid transparent;background:#fff;margin:.35rem 0;border-radius:.6rem;padding:.75rem;box-shadow:0 1px 5px #143d5510}.element.active{border-color:#2387d2;background:#f5fbff}.element-head{display:flex;justify-content:space-between;align-items:center;gap:.4rem;margin-bottom:.45rem}.element-head button.pick{flex:1;text-align:left;border:0;background:transparent;padding:0;color:#326383}.element-preview{white-space:pre-wrap;word-break:break-word}.element-preview h2,.element-preview h3{margin:.15rem 0;font-size:1.12rem}.element-preview p{margin:.3rem 0}.element-preview .placeholder{border:1px dashed #abc1d2;padding:.9rem;border-radius:.5rem;background:#eef5fb;color:#46677c}.element-preview blockquote{border-left:4px solid #94b8d4;padding-left:1rem;margin:.2rem 0}.inspector form{display:flex;flex-direction:column;gap:.75rem}.field{display:block;margin:.65rem 0;font-size:.84rem;font-weight:600}.field .type-hint{display:block;font-size:.72rem;color:#708297;font-weight:400}.field.checkbox{display:flex;align-items:center;gap:.4rem}.field.checkbox input{width:auto}.array-editor,.object-editor{border-left:3px solid #d9e8f4;margin:.6rem 0;padding:.25rem 0 .45rem .7rem}.array-item{border:1px solid #e1eaf1;border-radius:.5rem;padding:.5rem;margin:.5rem 0}.item-head{display:flex;justify-content:space-between;align-items:center;gap:.5rem}.empty{color:#6c7b88;padding:1.5rem .5rem}.footer{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;background:#fff;padding:.6rem 1.4rem;border-top:1px solid #dce5ed;font-size:.84rem;color:#5e7181}#status.error{color:#b32d36}#status.saved{color:#186744}.preview-label{font-size:.77rem;border:1px solid #c9d8e5;color:#51708b;border-radius:40px;padding:.2rem .6rem;white-space:nowrap}@media(max-width:1050px){.workspace{grid-template-columns:230px minmax(0,1fr)}.inspector{grid-column:1/-1;max-height:none}.side{max-height:none}}@media(max-width:680px){.workspace{display:flex;flex-direction:column}.side,.inspector{max-height:none}.page-meta{grid-template-columns:1fr}.canvas{padding:.65rem}.actions select{min-width:120px}.cells{grid-template-columns:1fr!important}}
@@ -0,0 +1,40 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="robots" content="noindex,nofollow">
7
+ <title>EdgePress · 可视化 Pages 编辑器</title>
8
+ <link rel="stylesheet" href="/editor.css">
9
+ <script type="module" src="/editor.js"></script>
10
+ </head>
11
+ <body>
12
+ <header class="topbar">
13
+ <div class="brand"><span class="brand-icon" aria-hidden="true">◧</span><div><strong>EdgePress</strong><small>Pages 可视化编辑器 · 本地运行</small></div></div>
14
+ <div class="actions">
15
+ <label for="page-picker">页面</label><select id="page-picker" aria-label="选择页面"></select>
16
+ <button id="new-page" type="button" class="subtle">+ 新建页面</button>
17
+ <button id="reload" type="button" class="subtle">重新载入</button>
18
+ <button id="save" type="button" class="primary">保存更改</button>
19
+ </div>
20
+ </header>
21
+ <main class="workspace">
22
+ <aside class="side panel" aria-label="页面结构">
23
+ <div class="panel-header"><h2>页面结构</h2><button id="add-row" type="button" title="添加页面行">+ 行</button></div>
24
+ <p class="help">选中元素后可调整内容、顺序和位置。</p>
25
+ <nav id="outline" aria-label="页面结构导航"></nav>
26
+ </aside>
27
+ <section class="center panel" aria-label="可视化页面画布">
28
+ <div class="panel-header"><div><h1 id="page-name">选择页面</h1><p id="page-path" class="muted"></p></div><span class="preview-label">结构与内容预览</span></div>
29
+ <div class="page-meta"><label>页面标题 <input id="title" maxlength="500" type="text"></label><label>摘要 <input id="description" type="text"></label></div>
30
+ <div class="canvas-wrap"><div id="canvas" class="canvas" aria-label="页面内容预览"></div></div>
31
+ <p class="help note">预览展示真实的行列顺序和主要内容,不是主题渲染或最终网页效果。正式效果请运行 <code>npm run dev</code>。</p>
32
+ </section>
33
+ <aside class="inspector panel" aria-label="元素设置">
34
+ <div class="panel-header"><h2>元素属性</h2><span id="selected-label" class="muted">尚未选择</span></div>
35
+ <div id="fields"><p class="empty">从中间的页面画布选择一个元素开始编辑。</p></div>
36
+ </aside>
37
+ </main>
38
+ <footer class="footer"><span id="status" role="status" aria-live="polite">正在载入页面…</span><span>只监听 127.0.0.1 · JSON 是唯一的数据源</span></footer>
39
+ </body>
40
+ </html>