minecodex 2.0.31 → 2.0.32
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/apps/dashboard/app.js +49 -39
- package/apps/dashboard/assets/data-transfer.js +19 -13
- package/apps/dashboard/assets/feature-previews.js +206 -0
- package/apps/dashboard/assets/languages.svg +18 -0
- package/apps/dashboard/assets/preview-content.js +41 -0
- package/apps/dashboard/assets/previews/manifest.json +1 -0
- package/apps/dashboard/index.html +24 -26
- package/apps/dashboard/styles.css +89 -98
- package/package.json +1 -1
- package/packages/cli/src/control-server.mjs +7 -2
- package/packages/plugin/assets/app.js +30 -30
- package/packages/plugin/assets/prompts.js +4 -3
- package/packages/plugin/src/app.mjs +0 -1
- package/packages/plugin/src/server.mjs +3 -1
- package/packages/plugin/src/ui-resource.mjs +18 -2
- package/apps/dashboard/assets/OFL.txt +0 -93
- package/apps/dashboard/assets/silkscreen.ttf +0 -0
- package/apps/dashboard/assets/terrain.js +0 -89
package/apps/dashboard/app.js
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
+
import {installFeaturePreviews} from "./assets/feature-previews.js";
|
|
1
2
|
import {diagnosticReport} from "./assets/diagnostics.js";
|
|
2
3
|
import {dashboardFetch} from "./transport.js";
|
|
3
4
|
let savedLocale;
|
|
4
5
|
try { savedLocale = localStorage.getItem("minecodex-language"); } catch { /* 存储不可用时跟随浏览器语言。 */ }
|
|
5
|
-
|
|
6
|
+
let locale = ["en", "zh-CN"].includes(savedLocale) ? savedLocale
|
|
6
7
|
: navigator.language.toLowerCase().startsWith("zh") ? "zh-CN" : "en";
|
|
7
8
|
document.documentElement.lang = locale;
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
+
const messages = {
|
|
10
11
|
en: {
|
|
11
|
-
connecting: "Connecting…",
|
|
12
|
-
runtimeActive: "Active in Codex",
|
|
13
|
-
runtimeStopped: "Runtime unavailable",
|
|
14
|
-
runtimeNotInjected: "Runtime running — restart Codex to apply plugins",
|
|
15
12
|
plugins: "Your extensions",
|
|
16
13
|
restartTitle: "Restart Codex to apply changes",
|
|
17
14
|
restartCopy: "Your plugin choices are saved. Restart when you are ready.",
|
|
@@ -53,10 +50,6 @@ const copy = {
|
|
|
53
50
|
failureDetail: "This extension could not be confirmed. Copy diagnostics to help investigate.",
|
|
54
51
|
},
|
|
55
52
|
"zh-CN": {
|
|
56
|
-
connecting: "正在连接…",
|
|
57
|
-
runtimeActive: "已在 Codex 中生效",
|
|
58
|
-
runtimeStopped: "运行服务不可用",
|
|
59
|
-
runtimeNotInjected: "运行服务已启动——重启 Codex 以应用插件",
|
|
60
53
|
plugins: "功能扩展",
|
|
61
54
|
restartTitle: "重启 Codex 以应用更改",
|
|
62
55
|
restartCopy: "插件选择已经保存,可以在准备好后统一重启。",
|
|
@@ -97,11 +90,10 @@ const copy = {
|
|
|
97
90
|
restartFailed: "MineCodex 无法重启 Codex。请重试或复制诊断信息协助排查。",
|
|
98
91
|
failureDetail: "暂时无法确认这项功能的状态,可复制诊断信息协助排查。",
|
|
99
92
|
},
|
|
100
|
-
}
|
|
93
|
+
};
|
|
94
|
+
let copy = messages[locale];
|
|
101
95
|
|
|
102
96
|
const elements = {
|
|
103
|
-
runtimeDot: document.querySelector("#runtime-dot"),
|
|
104
|
-
runtimeLabel: document.querySelector("#runtime-label"),
|
|
105
97
|
restartNotice: document.querySelector("#restart-notice"),
|
|
106
98
|
restartTitle: document.querySelector("#restart-title"),
|
|
107
99
|
restartCopy: document.querySelector("#restart-copy"),
|
|
@@ -128,11 +120,18 @@ const elements = {
|
|
|
128
120
|
let state = null;
|
|
129
121
|
let toastTimer = null;
|
|
130
122
|
const pendingFeatureIds = new Set();
|
|
123
|
+
const previews = installFeaturePreviews(elements.pluginList, locale);
|
|
131
124
|
|
|
132
125
|
function applyStaticCopy() {
|
|
126
|
+
document.querySelector("#brand-slogan").textContent = locale === "zh-CN" ? "让 Codex 更顺手。" : "Make Codex work better for you.";
|
|
127
|
+
document.querySelector("#brand-description").textContent = locale === "zh-CN" ? "按需开启交互增强,让日常使用更高效。" : "Enable the enhancements you need for a smoother daily workflow.";
|
|
128
|
+
const language = document.querySelector("#language-button");
|
|
129
|
+
const languageLabel = locale === "zh-CN" ? "语言 · 中文" : "Language · English";
|
|
130
|
+
language.setAttribute("aria-label", languageLabel);
|
|
131
|
+
language.setAttribute("data-minecodex-tooltip", languageLabel);
|
|
133
132
|
elements.languageToggle.setAttribute("aria-label", locale === "zh-CN" ? "语言" : "Language");
|
|
133
|
+
document.querySelector("#language-menu").setAttribute("aria-label", locale === "zh-CN" ? "语言" : "Language");
|
|
134
134
|
for (const button of elements.languageButtons) button.setAttribute("aria-pressed", String(button.dataset.languageChoice === locale));
|
|
135
|
-
elements.runtimeLabel.textContent = copy.connecting;
|
|
136
135
|
elements.restartTitle.textContent = copy.restartTitle;
|
|
137
136
|
elements.restartCopy.textContent = copy.restartCopy;
|
|
138
137
|
elements.restartButton.querySelector(".button-label").textContent = copy.restart;
|
|
@@ -209,6 +208,7 @@ function createPluginCard(feature) {
|
|
|
209
208
|
pluginState.append(dot, stateLabel);
|
|
210
209
|
heading.append(icon, label);
|
|
211
210
|
card.append(heading, pluginCopy, pluginState);
|
|
211
|
+
previews.attach(card);
|
|
212
212
|
return card;
|
|
213
213
|
}
|
|
214
214
|
|
|
@@ -230,6 +230,8 @@ function updatePluginCard(card, feature) {
|
|
|
230
230
|
input.setAttribute("aria-label", feature.label);
|
|
231
231
|
dot.className = `status-dot ${status.className}`;
|
|
232
232
|
stateLabel.textContent = status.label;
|
|
233
|
+
const previewEntry = card.querySelector(".preview-entry");
|
|
234
|
+
previewEntry?.setAttribute("aria-label", `${locale === "zh-CN" ? "查看效果" : "See how it works"} · ${feature.label}`);
|
|
233
235
|
}
|
|
234
236
|
|
|
235
237
|
function findPluginCard(featureId) {
|
|
@@ -243,38 +245,25 @@ function renderPluginCards(features) {
|
|
|
243
245
|
if (!child.dataset.featureId) child.remove();
|
|
244
246
|
}
|
|
245
247
|
const featureIds = new Set(features.map(({ id }) => id));
|
|
246
|
-
for (const feature of features) {
|
|
248
|
+
for (const [index, feature] of features.entries()) {
|
|
247
249
|
let card = findPluginCard(feature.id);
|
|
248
250
|
if (!card) {
|
|
249
251
|
card = createPluginCard(feature);
|
|
250
252
|
}
|
|
251
|
-
elements.pluginList.
|
|
253
|
+
const expected = elements.pluginList.children[index];
|
|
254
|
+
if (expected !== card) elements.pluginList.insertBefore(card, expected ?? null);
|
|
252
255
|
updatePluginCard(card, feature);
|
|
253
256
|
}
|
|
254
257
|
for (const card of [...elements.pluginList.querySelectorAll("[data-feature-id]")]) {
|
|
255
|
-
if (card.dataset.featureId && !featureIds.has(card.dataset.featureId))
|
|
258
|
+
if (card.dataset.featureId && !featureIds.has(card.dataset.featureId)) {
|
|
259
|
+
if (card.classList.contains("is-previewing")) previews.close();
|
|
260
|
+
card.remove();
|
|
261
|
+
}
|
|
256
262
|
}
|
|
257
263
|
elements.pluginList.setAttribute("aria-busy", "false");
|
|
258
264
|
}
|
|
259
265
|
|
|
260
266
|
function renderRuntime() {
|
|
261
|
-
const runtimeRunning = Boolean(state?.runtime?.running);
|
|
262
|
-
const renderer = state?.renderer;
|
|
263
|
-
const rendererCurrent = Boolean(
|
|
264
|
-
renderer?.discovered
|
|
265
|
-
&& renderer.runtimeSessionId
|
|
266
|
-
&& renderer.runtimeSessionId === state?.runtime?.runtimeSessionId
|
|
267
|
-
&& renderer.active
|
|
268
|
-
&& Number(renderer.injectedTargets) > 0,
|
|
269
|
-
);
|
|
270
|
-
const active = runtimeRunning && rendererCurrent;
|
|
271
|
-
elements.runtimeLabel.closest(".overview").hidden = active;
|
|
272
|
-
elements.runtimeDot.className = `status-dot ${active ? "is-active" : "is-warning"}`;
|
|
273
|
-
elements.runtimeLabel.textContent = active
|
|
274
|
-
? copy.runtimeActive
|
|
275
|
-
: runtimeRunning
|
|
276
|
-
? copy.runtimeNotInjected
|
|
277
|
-
: copy.runtimeStopped;
|
|
278
267
|
elements.versionValue.textContent = state?.version ?? "—";
|
|
279
268
|
elements.restartNotice.hidden = !state?.pendingRestart;
|
|
280
269
|
}
|
|
@@ -320,11 +309,9 @@ async function load() {
|
|
|
320
309
|
elements.pluginList.hidden = false;
|
|
321
310
|
render();
|
|
322
311
|
} catch {
|
|
312
|
+
previews.close();
|
|
323
313
|
elements.pluginList.hidden = true;
|
|
324
314
|
elements.loadError.hidden = false;
|
|
325
|
-
elements.runtimeDot.className = "status-dot is-warning";
|
|
326
|
-
elements.runtimeLabel.textContent = copy.runtimeStopped;
|
|
327
|
-
elements.runtimeLabel.closest(".overview").hidden = true;
|
|
328
315
|
}
|
|
329
316
|
}
|
|
330
317
|
|
|
@@ -432,11 +419,13 @@ function applyTheme() {
|
|
|
432
419
|
for (const button of elements.themeButtons) {
|
|
433
420
|
button.setAttribute("aria-label", button.dataset.themeChoice === "light" ? copy.lightAppearance : copy.darkAppearance);
|
|
434
421
|
button.setAttribute("aria-pressed", String(button.dataset.themeChoice === (dark ? "dark" : "light")));
|
|
422
|
+
button.setAttribute("data-minecodex-tooltip", button.getAttribute("aria-label"));
|
|
435
423
|
}
|
|
436
424
|
}
|
|
437
425
|
elements.themeToggle.addEventListener("click", (event) => {
|
|
438
426
|
const button = event.target.closest("[data-theme-choice]");
|
|
439
427
|
if (!button) return;
|
|
428
|
+
previews.close();
|
|
440
429
|
selectedTheme = button.dataset.themeChoice;
|
|
441
430
|
if (!window.minecodexMcpApp) {
|
|
442
431
|
try { localStorage.setItem("minecodex-appearance", selectedTheme); } catch { /* 保留内存中的选择。 */ }
|
|
@@ -449,8 +438,20 @@ elements.languageToggle.addEventListener("click", (event) => {
|
|
|
449
438
|
const choice = button.dataset.languageChoice;
|
|
450
439
|
if (choice === locale) return;
|
|
451
440
|
try { localStorage.setItem("minecodex-language", choice); } catch { return; }
|
|
452
|
-
//
|
|
453
|
-
|
|
441
|
+
// 浏览器沿用重载;官方 sandbox 在当前上下文内切换,保留主题与通信。
|
|
442
|
+
previews.close();
|
|
443
|
+
if (!window.minecodexMcpApp) { location.reload(); return; }
|
|
444
|
+
locale = choice;
|
|
445
|
+
copy = messages[locale];
|
|
446
|
+
document.documentElement.lang = locale;
|
|
447
|
+
previews.setLocale(locale);
|
|
448
|
+
document.querySelector("#language-menu").hidePopover();
|
|
449
|
+
applyStaticCopy();
|
|
450
|
+
applyTheme();
|
|
451
|
+
elements.updateResult.hidden = true;
|
|
452
|
+
elements.toast.hidden = true;
|
|
453
|
+
window.dispatchEvent(new Event("minecodex-language"));
|
|
454
|
+
void load();
|
|
454
455
|
});
|
|
455
456
|
systemTheme.addEventListener("change", applyTheme);
|
|
456
457
|
window.addEventListener("minecodex-host-theme", () => {
|
|
@@ -462,3 +463,12 @@ document.addEventListener("pointerdown", () => { document.documentElement.datase
|
|
|
462
463
|
applyStaticCopy();
|
|
463
464
|
applyTheme();
|
|
464
465
|
void load();
|
|
466
|
+
|
|
467
|
+
const languageMenu = document.querySelector("#language-menu");
|
|
468
|
+
languageMenu.addEventListener("toggle", event => {
|
|
469
|
+
document.querySelector("#language-button").setAttribute("aria-expanded", String(event.newState === "open"));
|
|
470
|
+
if (event.newState !== "open") return;
|
|
471
|
+
const rect = document.querySelector("#language-button").getBoundingClientRect();
|
|
472
|
+
languageMenu.style.left = `${Math.max(8, Math.min(rect.right - 140, innerWidth - 148))}px`;
|
|
473
|
+
languageMenu.style.top = `${rect.bottom + 8}px`;
|
|
474
|
+
});
|
|
@@ -3,7 +3,7 @@ import {dashboardFetch} from '../transport.js';
|
|
|
3
3
|
const tooltipLifetime = new AbortController();
|
|
4
4
|
const { tooltipSurface } = installFeatureTooltips(tooltipLifetime.signal);
|
|
5
5
|
window.addEventListener('pagehide', event => { if (!event.persisted) tooltipLifetime.abort(); });
|
|
6
|
-
|
|
6
|
+
let zh = document.documentElement.lang.startsWith('zh');
|
|
7
7
|
const t = (cn, en) => zh ? cn : en;
|
|
8
8
|
const dialog = document.querySelector('#data-dialog');
|
|
9
9
|
const entry = document.querySelector('#data-button');
|
|
@@ -14,12 +14,18 @@ const exportButton = document.querySelector('#data-export');
|
|
|
14
14
|
const importButton = document.querySelector('#data-import');
|
|
15
15
|
const input = document.querySelector('#data-file');
|
|
16
16
|
let bundle = null, busy = false;
|
|
17
|
-
|
|
18
|
-
document.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
function localize() {
|
|
18
|
+
zh = document.documentElement.lang.startsWith('zh');
|
|
19
|
+
Object.assign(featureNames, {notes:t('提示词库','Prompt Library'),sites:t('站点收藏','Saved Sites'),'model-slider':t('模型窗口','Model Panel')});
|
|
20
|
+
entry.textContent = t('数据迁移', 'Transfer data');
|
|
21
|
+
document.querySelector('#data-title').textContent = t('数据迁移', 'Transfer data');
|
|
22
|
+
document.querySelector('#data-policy').textContent = t('仅补充缺失项,重复跳过,冲突保留本机。', 'Add missing items. Skip duplicates. Keep local conflicts.');
|
|
23
|
+
document.querySelector('#data-close').setAttribute('aria-label', t('关闭', 'Close'));
|
|
24
|
+
exportButton.querySelector('.button-label').textContent = t('导出', 'Export');
|
|
25
|
+
importButton.querySelector('.button-label').textContent = t('导入', 'Import');
|
|
26
|
+
}
|
|
27
|
+
localize();
|
|
28
|
+
window.addEventListener('minecodex-language', () => { localize(); if (dialog.open) dialog.close(); });
|
|
23
29
|
async function request(url, data) {
|
|
24
30
|
const response = await dashboardFetch(url, data ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) } : {});
|
|
25
31
|
if (!response.ok) throw new Error(String(response.status));
|
|
@@ -32,11 +38,11 @@ function setBusy(value) {
|
|
|
32
38
|
document.querySelector('#data-close').disabled = value;
|
|
33
39
|
list.setAttribute('aria-busy', String(value));
|
|
34
40
|
}
|
|
35
|
-
const featureHints = {
|
|
41
|
+
const featureHints = () => ({
|
|
36
42
|
'model-slider': t('仅匹配相同模型标识。本机缺少的模型保留收藏,不显示选项;导入后下次启动 Codex 生效。', 'Only exact model IDs match. Missing models stay saved but hidden. Imported favorites load next time Codex starts.'),
|
|
37
43
|
notes: t('包含分类、排序与使用次数。', 'Includes categories, order and usage counts.'),
|
|
38
44
|
sites: t('仅迁移公网网址,导入后归为外部网址。', 'Public URLs only; imported as external links.'),
|
|
39
|
-
};
|
|
45
|
+
});
|
|
40
46
|
let hintIndex = 0;
|
|
41
47
|
function row(name, detail, amount, hint = '') {
|
|
42
48
|
const item = document.createElement('div'); item.className = 'data-card';
|
|
@@ -70,14 +76,14 @@ function showInventory(data) {
|
|
|
70
76
|
for (const feature of data.features.filter(item => item.portable)) {
|
|
71
77
|
const detail = feature.id === 'notes' ? t(`${feature.categories} 个分类`, `${feature.categories} categories`) : '';
|
|
72
78
|
const hint = feature.id === 'model-slider' && !feature.initialized
|
|
73
|
-
? t('旧收藏在新版模型窗口首次启动后收录。', 'Legacy favorites are collected when the updated slider first starts.') + ' ' + featureHints[feature.id]
|
|
74
|
-
: featureHints[feature.id];
|
|
79
|
+
? t('旧收藏在新版模型窗口首次启动后收录。', 'Legacy favorites are collected when the updated slider first starts.') + ' ' + featureHints()[feature.id]
|
|
80
|
+
: featureHints()[feature.id];
|
|
75
81
|
row(featureNames[feature.id] || feature.id, detail, t(`${feature.count} 项`, `${feature.count} items`), hint);
|
|
76
82
|
}
|
|
77
83
|
}
|
|
78
84
|
function showPreview(data) {
|
|
79
85
|
list.replaceChildren();
|
|
80
|
-
for (const feature of data.features) row(featureNames[feature.id] || feature.id, t(`${feature.duplicate} 项重复 · ${feature.conflict} 项冲突,保留本机${feature.categoriesAdded ? ` · 新增 ${feature.categoriesAdded} 个分类` : ''}`, `${feature.duplicate} duplicates · ${feature.conflict} conflicts kept locally${feature.categoriesAdded ? ` · ${feature.categoriesAdded} new categories` : ''}`), t(`新增 ${feature.added} 项`, `${feature.added} new`), featureHints[feature.id]);
|
|
86
|
+
for (const feature of data.features) row(featureNames[feature.id] || feature.id, t(`${feature.duplicate} 项重复 · ${feature.conflict} 项冲突,保留本机${feature.categoriesAdded ? ` · 新增 ${feature.categoriesAdded} 个分类` : ''}`, `${feature.duplicate} duplicates · ${feature.conflict} conflicts kept locally${feature.categoriesAdded ? ` · ${feature.categoriesAdded} new categories` : ''}`), t(`新增 ${feature.added} 项`, `${feature.added} new`), featureHints()[feature.id]);
|
|
81
87
|
if (!data.features.length) row(t('空文件', 'Empty file'), t('没有可导入的数据', 'No data to import'), '0');
|
|
82
88
|
}
|
|
83
89
|
entry.addEventListener('click', async () => {
|
|
@@ -121,7 +127,7 @@ importButton.addEventListener('click', async () => {
|
|
|
121
127
|
try {
|
|
122
128
|
const result = await request('/api/data/import', bundle);
|
|
123
129
|
list.replaceChildren();
|
|
124
|
-
for (const feature of result.features) row(featureNames[feature.id] || feature.id, feature.status === 'failed' ? t('可重试', 'Retry available') : t(`跳过 ${feature.duplicate} 项重复 · 保留 ${feature.conflict} 项冲突${feature.categoriesAdded ? ` · 新增 ${feature.categoriesAdded} 个分类` : ''}`, `${feature.duplicate} duplicates skipped · ${feature.conflict} conflicts kept${feature.categoriesAdded ? ` · ${feature.categoriesAdded} new categories` : ''}`), feature.status === 'failed' ? t('失败', 'Failed') : t(`新增 ${feature.added} 项`, `${feature.added} added`), featureHints[feature.id]);
|
|
130
|
+
for (const feature of result.features) row(featureNames[feature.id] || feature.id, feature.status === 'failed' ? t('可重试', 'Retry available') : t(`跳过 ${feature.duplicate} 项重复 · 保留 ${feature.conflict} 项冲突${feature.categoriesAdded ? ` · 新增 ${feature.categoriesAdded} 个分类` : ''}`, `${feature.duplicate} duplicates skipped · ${feature.conflict} conflicts kept${feature.categoriesAdded ? ` · ${feature.categoriesAdded} new categories` : ''}`), feature.status === 'failed' ? t('失败', 'Failed') : t(`新增 ${feature.added} 项`, `${feature.added} added`), featureHints()[feature.id]);
|
|
125
131
|
status.textContent = result.complete ? t('导入完成', 'Import complete') : t('部分失败,请重试', 'Partially imported. Retry.');
|
|
126
132
|
if (result.complete) bundle = null;
|
|
127
133
|
importButton.querySelector('.button-label').textContent = result.complete ? t('导入', 'Import') : t('重试导入', 'Retry import');
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {previewContent} from './preview-content.js';
|
|
2
|
+
|
|
3
|
+
export function installFeaturePreviews(grid, locale) {
|
|
4
|
+
let zh = locale === 'zh-CN';
|
|
5
|
+
const t = (cn, en) => zh ? cn : en;
|
|
6
|
+
const lifetime = new AbortController();
|
|
7
|
+
const on = (node, type, fn, options = {}) => node.addEventListener(type, fn, {...options, signal: lifetime.signal});
|
|
8
|
+
const fine = matchMedia('(hover: hover) and (pointer: fine)');
|
|
9
|
+
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
|
10
|
+
let current = null, panel = null, video = null, enterTimer, leaveTimer, replayTimer, pinned = false, generation = 0, restoring = false, mediaAbort = null;
|
|
11
|
+
let manifest, hoveredCard = null, hoveredPanel = false, pendingCard = null;
|
|
12
|
+
const media = () => manifest ??= window.minecodexPreviewMedia !== undefined
|
|
13
|
+
? Promise.resolve(window.minecodexPreviewMedia)
|
|
14
|
+
: fetch('/assets/previews/manifest.json').then(r => r.ok ? r.json() : {}).then(value => value && typeof value === 'object' ? value : {}).catch(() => ({}));
|
|
15
|
+
const node = (tag, cls, text) => {
|
|
16
|
+
const el = document.createElement(tag); el.className = cls; if (text) el.textContent = text; return el;
|
|
17
|
+
};
|
|
18
|
+
async function resourceUrl(id, kind, value, signal) {
|
|
19
|
+
if (!window.minecodexMcpApp) return `/assets/previews/${value}`;
|
|
20
|
+
const response = await window.minecodexMcpApp.readServerResource({uri:`resource://minecodex/previews/${id}/${kind}`}, {signal});
|
|
21
|
+
const resource = response.contents?.[0];
|
|
22
|
+
if (!resource?.blob) throw new Error('Preview resource unavailable');
|
|
23
|
+
return `data:${resource.mimeType};base64,${resource.blob}`;
|
|
24
|
+
}
|
|
25
|
+
function close({restore = false} = {}) {
|
|
26
|
+
clearTimeout(enterTimer); clearTimeout(leaveTimer); clearTimeout(replayTimer); generation++;
|
|
27
|
+
mediaAbort?.abort(); mediaAbort = null;
|
|
28
|
+
// 先释放状态再移除节点,避免焦点退出事件重入清理。
|
|
29
|
+
const closingPanel = panel, card = current, player = video;
|
|
30
|
+
panel = null; current = null; video = null; pinned = false;
|
|
31
|
+
if (player) { player.pause(); player.removeAttribute('src'); player.load(); }
|
|
32
|
+
const trigger = card?.querySelector('.preview-entry');
|
|
33
|
+
trigger?.setAttribute('aria-expanded', 'false');
|
|
34
|
+
trigger?.removeAttribute('aria-controls');
|
|
35
|
+
card?.classList.remove('is-previewing');
|
|
36
|
+
grid.classList.remove('has-preview');
|
|
37
|
+
closingPanel?.remove();
|
|
38
|
+
if (restore && trigger?.isConnected) { restoring = true; trigger.focus({preventScroll: true}); restoring = false; }
|
|
39
|
+
}
|
|
40
|
+
function position() {
|
|
41
|
+
if (!current || !panel) return;
|
|
42
|
+
const rect = current.getBoundingClientRect();
|
|
43
|
+
const width = Math.min(360, innerWidth - 32);
|
|
44
|
+
panel.style.width = `${width}px`;
|
|
45
|
+
panel.style.maxHeight = `${Math.max(120, innerHeight - 32)}px`;
|
|
46
|
+
const height = panel.getBoundingClientRect().height;
|
|
47
|
+
let left, top;
|
|
48
|
+
if (innerWidth - rect.right >= width + 24) left = rect.right + 8;
|
|
49
|
+
else if (rect.left >= width + 24) left = rect.left - width - 8;
|
|
50
|
+
else {
|
|
51
|
+
left = Math.max(16, Math.min(rect.left, innerWidth - width - 16));
|
|
52
|
+
// 窄屏把面板放到开关下方,避免挡住当前操作。
|
|
53
|
+
const below = innerHeight - rect.bottom - 24, above = rect.top - 24;
|
|
54
|
+
if (below >= Math.min(height, 180)) {
|
|
55
|
+
panel.style.maxHeight = `${below}px`; top = rect.bottom + 8;
|
|
56
|
+
} else if (above >= Math.min(height, 180)) {
|
|
57
|
+
panel.style.maxHeight = `${above}px`; top = Math.max(16, rect.top - height - 8);
|
|
58
|
+
} else {
|
|
59
|
+
top = Math.max(16, rect.top + current.querySelector('.plugin-topline').getBoundingClientRect().height + 32);
|
|
60
|
+
panel.style.maxHeight = `${Math.max(100, innerHeight - top - 16)}px`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
top ??= Math.max(16, Math.min(rect.top, innerHeight - height - 16));
|
|
64
|
+
Object.assign(panel.style, {left: `${left}px`, top: `${top}px`, visibility: 'visible'});
|
|
65
|
+
}
|
|
66
|
+
function scheduleClose() {
|
|
67
|
+
if (!pinned && !hoveredCard && !hoveredPanel) leaveTimer = setTimeout(() => close(), 100);
|
|
68
|
+
}
|
|
69
|
+
async function addMedia(id, host, state, ticket) {
|
|
70
|
+
const resources = (await media())?.[id];
|
|
71
|
+
if (ticket !== generation || !panel || !resources) return;
|
|
72
|
+
mediaAbort = new AbortController();
|
|
73
|
+
const mediaSignal = mediaAbort.signal;
|
|
74
|
+
let posterUrl, videoUrl, resourceFailed = false;
|
|
75
|
+
state.textContent = t('正在加载预览…', 'Loading preview…');
|
|
76
|
+
const urls = await Promise.all(['poster', 'video'].map(async kind => {
|
|
77
|
+
if (!resources[kind]) return null;
|
|
78
|
+
try { return await resourceUrl(id, kind, resources[kind], mediaSignal); }
|
|
79
|
+
catch { resourceFailed = true; return null; }
|
|
80
|
+
}));
|
|
81
|
+
if (ticket !== generation) return;
|
|
82
|
+
[posterUrl, videoUrl] = urls;
|
|
83
|
+
state.textContent = resourceFailed ? t('资源暂不可用 · 请参考说明', 'Preview unavailable · See description') : t('演示视频准备中', 'Demo video coming soon');
|
|
84
|
+
let poster = null;
|
|
85
|
+
if (posterUrl) {
|
|
86
|
+
poster = node('img', 'preview-poster'); poster.alt = t('功能静态预览', 'Static feature preview');
|
|
87
|
+
poster.addEventListener('load', position, {once: true});
|
|
88
|
+
poster.addEventListener('error', () => { poster.remove(); resourceFailed = true; if (!video) state.textContent = t('资源暂不可用 · 请参考说明', 'Preview unavailable · See description'); position(); }, {once: true});
|
|
89
|
+
poster.src = posterUrl; host.append(poster);
|
|
90
|
+
state.textContent = t('静态预览', 'Static preview');
|
|
91
|
+
}
|
|
92
|
+
if (!videoUrl) { position(); return; }
|
|
93
|
+
if (reduced.matches) { position(); return; }
|
|
94
|
+
const player = node('video', 'preview-video'); video = player;
|
|
95
|
+
player.muted = true; player.playsInline = true; player.preload = 'metadata'; player.hidden = true;
|
|
96
|
+
const fallback = () => {
|
|
97
|
+
panel.classList.remove('is-media-only');
|
|
98
|
+
if (poster) poster.hidden = false;
|
|
99
|
+
};
|
|
100
|
+
const failed = () => {
|
|
101
|
+
if (ticket !== generation) return;
|
|
102
|
+
clearTimeout(replayTimer); player.pause(); player.removeAttribute('src'); player.load(); player.remove(); video = null; fallback();
|
|
103
|
+
state.textContent = poster?.isConnected ? t('视频暂不可用 · 静态预览', 'Video unavailable · Static preview') : t('视频暂不可用 · 请参考说明', 'Video unavailable · See description'); position();
|
|
104
|
+
};
|
|
105
|
+
player.addEventListener('error', failed, {once: true});
|
|
106
|
+
player.addEventListener('loadeddata', () => {
|
|
107
|
+
if (ticket !== generation) return;
|
|
108
|
+
position();
|
|
109
|
+
});
|
|
110
|
+
player.addEventListener('play', () => {
|
|
111
|
+
if (ticket !== generation) return;
|
|
112
|
+
player.hidden = false; if (poster) poster.hidden = true;
|
|
113
|
+
panel.classList.add('is-media-only'); position();
|
|
114
|
+
});
|
|
115
|
+
const playVideo = async () => {
|
|
116
|
+
if (ticket !== generation) return;
|
|
117
|
+
try { await player.play(); }
|
|
118
|
+
catch {
|
|
119
|
+
if (ticket === generation && video === player && player.isConnected) {
|
|
120
|
+
player.hidden = true; fallback();
|
|
121
|
+
state.textContent = poster?.isConnected ? t('静态预览', 'Static preview') : t('演示视频准备中', 'Demo video coming soon'); position();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
player.addEventListener('ended', () => {
|
|
126
|
+
clearTimeout(replayTimer);
|
|
127
|
+
replayTimer = setTimeout(() => {
|
|
128
|
+
if (ticket !== generation || video !== player) return;
|
|
129
|
+
player.currentTime = 0; void playVideo();
|
|
130
|
+
}, 2000);
|
|
131
|
+
});
|
|
132
|
+
host.append(player); state.textContent = t('正在加载演示…', 'Loading demo…');
|
|
133
|
+
player.src = videoUrl;
|
|
134
|
+
void playVideo();
|
|
135
|
+
position();
|
|
136
|
+
}
|
|
137
|
+
function open(card, fixed = false) {
|
|
138
|
+
clearTimeout(enterTimer); clearTimeout(leaveTimer);
|
|
139
|
+
if (card === current) { pinned ||= fixed; return; }
|
|
140
|
+
close(); hoveredPanel = false; current = card; pinned = fixed;
|
|
141
|
+
panel = node('section', 'feature-preview'); panel.id = 'feature-preview'; panel.setAttribute('role', 'region');
|
|
142
|
+
const title = node('h3', 'preview-title', card.querySelector('.plugin-name').textContent);
|
|
143
|
+
title.id = 'feature-preview-title'; panel.setAttribute('aria-labelledby', title.id);
|
|
144
|
+
const dismiss = node('button', 'button quiet preview-close', t('关闭', 'Close')); dismiss.type = 'button';
|
|
145
|
+
dismiss.addEventListener('click', () => close({restore: pinned || panel.contains(document.activeElement)}));
|
|
146
|
+
const heading = node('div', 'preview-heading'); heading.append(title, dismiss); panel.append(heading);
|
|
147
|
+
const host = node('div', 'preview-media'); panel.append(host);
|
|
148
|
+
const details = node('dl', 'preview-details');
|
|
149
|
+
const labels = [t('出现位置', 'Location'), t('解决问题', 'Problem'), t('操作与效果', 'Action and result')];
|
|
150
|
+
previewContent(card.dataset.featureId, locale).forEach((value, index) => {
|
|
151
|
+
details.append(node('dt', '', labels[index]), node('dd', '', value));
|
|
152
|
+
});
|
|
153
|
+
const state = node('p', 'preview-resource-state', t('演示视频准备中', 'Demo video coming soon'));
|
|
154
|
+
panel.append(details, state);
|
|
155
|
+
panel.addEventListener('pointerenter', () => { hoveredPanel = true; clearTimeout(leaveTimer); });
|
|
156
|
+
panel.addEventListener('pointerleave', () => { hoveredPanel = false; scheduleClose(); });
|
|
157
|
+
panel.addEventListener('focusin', () => { clearTimeout(leaveTimer); });
|
|
158
|
+
panel.addEventListener('focusout', event => { if (!panel?.contains(event.relatedTarget) && !current?.contains(event.relatedTarget)) close(); });
|
|
159
|
+
document.querySelector('.dashboard-surface').append(panel);
|
|
160
|
+
card.classList.add('is-previewing'); grid.classList.add('has-preview');
|
|
161
|
+
const trigger = card.querySelector('.preview-entry'); trigger.setAttribute('aria-expanded', 'true'); trigger.setAttribute('aria-controls', panel.id);
|
|
162
|
+
position(); void addMedia(card.dataset.featureId, host, state, generation);
|
|
163
|
+
}
|
|
164
|
+
function attach(card) {
|
|
165
|
+
const entry = node('button', 'preview-entry'); entry.type = 'button'; entry.setAttribute('aria-expanded', 'false');
|
|
166
|
+
entry.setAttribute('aria-label', `${t('查看效果', 'See how it works')} · ${card.querySelector('.plugin-name').textContent || card.dataset.featureId}`);
|
|
167
|
+
card.append(entry);
|
|
168
|
+
on(card, 'pointerenter', () => {
|
|
169
|
+
if (!fine.matches) return;
|
|
170
|
+
hoveredCard = card; pendingCard = card;
|
|
171
|
+
|
|
172
|
+
clearTimeout(enterTimer); clearTimeout(leaveTimer);
|
|
173
|
+
enterTimer = setTimeout(() => { pendingCard = null; open(card); }, 200);
|
|
174
|
+
});
|
|
175
|
+
on(card, 'pointerleave', () => {
|
|
176
|
+
if (hoveredCard === card) hoveredCard = null;
|
|
177
|
+
if (pendingCard === card) { clearTimeout(enterTimer); pendingCard = null; }
|
|
178
|
+
scheduleClose();
|
|
179
|
+
});
|
|
180
|
+
on(entry, 'focus', () => { if (!restoring) open(card); });
|
|
181
|
+
on(entry, 'click', () => open(card, true));
|
|
182
|
+
on(card, 'focusout', event => { if (!card.contains(event.relatedTarget) && !panel?.contains(event.relatedTarget) && document.documentElement.dataset.input === 'keyboard') close(); });
|
|
183
|
+
}
|
|
184
|
+
on(document, 'keydown', event => {
|
|
185
|
+
if (event.key === 'Escape' && current) { event.preventDefault(); const restore = pinned || panel.contains(document.activeElement); close({restore}); }
|
|
186
|
+
});
|
|
187
|
+
on(document, 'pointerdown', event => {
|
|
188
|
+
if (!current) return;
|
|
189
|
+
if (!current.contains(event.target) && !panel.contains(event.target)) close();
|
|
190
|
+
});
|
|
191
|
+
on(window, 'scroll', event => {
|
|
192
|
+
if (panel?.contains(event.target)) return;
|
|
193
|
+
close();
|
|
194
|
+
}, {capture: true});
|
|
195
|
+
on(window, 'resize', () => close());
|
|
196
|
+
on(document, 'visibilitychange', () => { if (document.hidden) close(); });
|
|
197
|
+
on(window, 'blur', () => close());
|
|
198
|
+
on(document.querySelector('#data-dialog'), 'toggle', () => close());
|
|
199
|
+
on(document.querySelector('#language-menu'), 'toggle', () => close());
|
|
200
|
+
on(window, 'pagehide', () => { close(); lifetime.abort(); });
|
|
201
|
+
function setLocale(next) {
|
|
202
|
+
close(); locale = next; zh = locale === 'zh-CN';
|
|
203
|
+
for (const card of grid.querySelectorAll('.plugin-card')) card.querySelector('.preview-entry')?.setAttribute('aria-label', `${t('查看效果', 'See how it works')} · ${card.querySelector('.plugin-name').textContent}`);
|
|
204
|
+
}
|
|
205
|
+
return {attach, close, setLocale};
|
|
206
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg
|
|
2
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
3
|
+
width="24"
|
|
4
|
+
height="24"
|
|
5
|
+
viewBox="0 0 24 24"
|
|
6
|
+
fill="none"
|
|
7
|
+
stroke="currentColor"
|
|
8
|
+
stroke-width="2"
|
|
9
|
+
stroke-linecap="round"
|
|
10
|
+
stroke-linejoin="round"
|
|
11
|
+
>
|
|
12
|
+
<path d="m5 8 6 6" />
|
|
13
|
+
<path d="m4 14 6-6 2-3" />
|
|
14
|
+
<path d="M2 5h12" />
|
|
15
|
+
<path d="M7 2h1" />
|
|
16
|
+
<path d="m22 22-5-10-5 10" />
|
|
17
|
+
<path d="M14 18h6" />
|
|
18
|
+
</svg>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// 稳定 feature ID 对应功能说明;演示资源与实时状态分开维护。
|
|
2
|
+
const content = {
|
|
3
|
+
'model-slider': [
|
|
4
|
+
['输入框的模型选择区域。', '模型与思考档位分散,切换不够直接。', '打开模型窗口,选择模型与思考档位;收藏常用模型以便再次选择。'],
|
|
5
|
+
['The model selector beside the composer.', 'Switching models and reasoning levels takes extra steps.', 'Open the model panel, choose a model and reasoning level, and save favorites for quick access.']],
|
|
6
|
+
notes: [
|
|
7
|
+
['官方 Prompt 页面与输入框旁的 Quick Prompts。', '常用提示词需要反复查找和复制。', '按分类管理提示词,从 Quick Prompts 填入当前输入框;填入后由你检查并发送。'],
|
|
8
|
+
['The Prompt page and Quick Prompts beside the composer.', 'Reusable prompts are hard to find and copy repeatedly.', 'Organize prompts by category and insert one into the current composer. Review it before sending.']],
|
|
9
|
+
'usage-meter': [
|
|
10
|
+
['Codex 侧栏的额度环。', '查看剩余额度需要打开账号菜单。', '用额度环查看使用比例,点击打开用量详情。'],
|
|
11
|
+
['The usage ring in the Codex sidebar.', 'Checking remaining usage requires opening the account menu.', 'Read usage at a glance, then click for usage details.']],
|
|
12
|
+
'session-status': [
|
|
13
|
+
['当前会话的状态区域。', '会话用量与生成速度不容易集中查看。', '查看当前会话的用量、上下文和生成指标;可用项目取决于会话与提供方。'],
|
|
14
|
+
['The current conversation’s status area.', 'Usage and generation metrics are spread across the interface.', 'Read session usage, context and generation metrics when supported by the session and provider.']],
|
|
15
|
+
'markdown-reading': [
|
|
16
|
+
['Codex 的 Markdown 文件预览。', '长文阅读、脚注跳转和表格查看不够方便。', '在文件预览中阅读增强排版,使用目录、脚注和表格操作。'],
|
|
17
|
+
['Markdown file previews in Codex.', 'Long documents, footnotes and tables take extra effort to read.', 'Read enhanced formatting and use the outline, footnote navigation and table actions.']],
|
|
18
|
+
'file-navigation': [
|
|
19
|
+
['Codex 文件预览上方的路径区域。', '浏览相邻目录和文件需要额外跳转。', '点击路径展开分列目录,逐级浏览并打开另一个文件。'],
|
|
20
|
+
['The path above a file preview in Codex.', 'Browsing nearby files and folders takes extra navigation.', 'Open the path to browse cascading folder columns and choose another file.']],
|
|
21
|
+
images: [
|
|
22
|
+
['Codex Library 的图片集合与图片查看器。', '查找图片及其提示词需要来回切换。', '浏览图片集合,打开图片查看大图及可追溯的提示词信息。'],
|
|
23
|
+
['Image collections in Codex Library and the image viewer.', 'Finding images and their prompts requires switching views.', 'Browse collections, open an image, and inspect available prompt provenance.']],
|
|
24
|
+
sites: [
|
|
25
|
+
['Codex 中的 Sites 页面。', '本地项目与常用网址分散在不同位置。', '收藏网址、发现正在运行的本地站点,并从 Sites 打开。'],
|
|
26
|
+
['The Sites page in Codex.', 'Local projects and saved links are scattered.', 'Save URLs, discover running local sites, and open them from Sites.']],
|
|
27
|
+
'quick-chat': [
|
|
28
|
+
['Codex 的浮动 Quick Chat 窗口。', '临时提问时需要切换主窗口,历史不易继续。', '通过快捷键打开 Quick Chat,发送后展开,恢复最小化输入框并固定聊天。'],
|
|
29
|
+
['The floating Quick Chat window.', 'Quick questions and continuing recent chats require window switching.', 'Open Quick Chat with a shortcut, expand after sending, restore a minimized composer and pin the chat.']],
|
|
30
|
+
'sidebar-focus': [
|
|
31
|
+
['Codex 左侧栏的分组标题。', '多个侧栏分组展开时,当前分组不容易聚焦。', '点击当前分组,一键收起其他分组;再次点击恢复先前的展开状态。'],
|
|
32
|
+
['Group headings in the Codex sidebar.', 'Several expanded groups make the current group harder to focus on.', 'Click a group to collapse other groups. Click again to restore their previous expanded state.']],
|
|
33
|
+
'hide-upsell-banner': [
|
|
34
|
+
['Codex 的额度推广提示区域。', '推广提示占用日常操作空间。', '隐藏额度推广提示,保留真实额度与运行错误反馈。'],
|
|
35
|
+
['Usage promotion banners in Codex.', 'Promotions take space during everyday tasks.', 'Hide usage promotions while retaining real usage and error feedback.']],
|
|
36
|
+
};
|
|
37
|
+
export function previewContent(id, locale) {
|
|
38
|
+
return content[id]?.[locale === 'zh-CN' ? 0 : 1] ?? (locale === 'zh-CN'
|
|
39
|
+
? ['Codex 中对应功能的位置。', '请参考功能说明。', '效果取决于当前版本与适用页面。']
|
|
40
|
+
: ['The feature’s location in Codex.', 'Refer to the feature description.', 'Availability depends on the version and current page.']);
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -6,38 +6,32 @@
|
|
|
6
6
|
<meta name="color-scheme" content="light dark">
|
|
7
7
|
<title>MineCodex</title>
|
|
8
8
|
<link rel="icon" href="/favicon.svg?v=minecodex-1" type="image/svg+xml">
|
|
9
|
-
<link rel="stylesheet" href="/styles.css?v=mcx-
|
|
10
|
-
<script type="module" src="/app.js?v=mcx-
|
|
11
|
-
<script type="module" src="/assets/terrain.js"></script>
|
|
9
|
+
<link rel="stylesheet" href="/styles.css?v=mcx-native-1">
|
|
10
|
+
<script type="module" src="/app.js?v=mcx-native-1"></script>
|
|
12
11
|
<script type="module" src="/assets/data-transfer.js"></script>
|
|
13
12
|
</head>
|
|
14
13
|
<body>
|
|
15
14
|
<div class="dashboard-surface">
|
|
16
|
-
<canvas id="agent-world" aria-hidden="true"></canvas>
|
|
17
15
|
<main class="shell">
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<button data-language-choice="en" type="button" lang="en" aria-pressed="false">EN</button>
|
|
16
|
+
<div class="header-actions">
|
|
17
|
+
<div class="language-toggle">
|
|
18
|
+
<button id="language-button" class="language-button" type="button" popovertarget="language-menu" aria-haspopup="true" aria-expanded="false"><span class="ui-icon language-icon" aria-hidden="true"></span></button>
|
|
19
|
+
<div id="language-menu" class="language-menu" popover="auto" role="group" aria-label="Language">
|
|
20
|
+
<button data-language-choice="zh-CN" type="button" lang="zh-CN" aria-pressed="false">中文</button>
|
|
21
|
+
<button data-language-choice="en" type="button" lang="en" aria-pressed="false">English</button>
|
|
22
|
+
</div>
|
|
26
23
|
</div>
|
|
27
24
|
<div class="theme-toggle" role="group" aria-label="Appearance">
|
|
28
25
|
<span class="theme-selection" aria-hidden="true"></span>
|
|
29
26
|
<button data-theme-choice="light" type="button" aria-label="Light appearance" aria-pressed="false"><span class="ui-icon sun-icon" aria-hidden="true"></span></button>
|
|
30
27
|
<button data-theme-choice="dark" type="button" aria-label="Dark appearance" aria-pressed="false"><span class="ui-icon moon-icon" aria-hidden="true"></span></button>
|
|
31
28
|
</div>
|
|
32
|
-
</div>
|
|
33
|
-
</header>
|
|
34
|
-
<div class="overview">
|
|
35
|
-
<div class="runtime-state" aria-live="polite">
|
|
36
|
-
<span id="runtime-dot" class="status-dot" aria-hidden="true"></span>
|
|
37
|
-
<span id="runtime-label">Connecting…</span>
|
|
38
|
-
</div>
|
|
39
29
|
</div>
|
|
40
|
-
|
|
30
|
+
<header class="page-header">
|
|
31
|
+
<h1><span class="brand-icon" aria-hidden="true"></span><span class="brand-name">MineCodex</span></h1>
|
|
32
|
+
<p id="brand-slogan" class="brand-slogan"></p>
|
|
33
|
+
<p id="brand-description" class="brand-description"></p>
|
|
34
|
+
</header>
|
|
41
35
|
<section id="restart-notice" class="restart-notice" hidden aria-live="polite">
|
|
42
36
|
<span class="notice-icon ui-icon info-icon" aria-hidden="true"></span>
|
|
43
37
|
<div class="notice-copy">
|
|
@@ -70,12 +64,16 @@
|
|
|
70
64
|
</section>
|
|
71
65
|
|
|
72
66
|
<footer class="diagnostics">
|
|
73
|
-
<
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
67
|
+
<div class="diagnostics-version">
|
|
68
|
+
<dl>
|
|
69
|
+
<div><dt id="version-label">Version</dt><dd id="version-value">—</dd></div>
|
|
70
|
+
</dl>
|
|
71
|
+
<button id="update-button" class="button quiet" type="button"><span class="ui-icon refresh-icon" aria-hidden="true"></span><span class="button-label">Check for updates</span></button>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="diagnostics-actions">
|
|
74
|
+
<button id="data-button" class="button secondary" type="button">Transfer data</button>
|
|
75
|
+
<button id="diagnostics-button" class="button secondary" type="button">Copy diagnostics</button>
|
|
76
|
+
</div>
|
|
79
77
|
</footer>
|
|
80
78
|
<p id="update-result" class="update-result" role="status" hidden></p>
|
|
81
79
|
</main>
|