openclacky 1.4.0 → 1.4.1

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.
@@ -50,6 +50,12 @@
50
50
  "err.generic": "Something went wrong: {{msg}}",
51
51
  "err.version_conflict": "Version {{ver}} has already been published. Please enter a higher version number above and try again.",
52
52
  "err.invalid_version": "Invalid version. Use format like 1.0.0.",
53
+ "err.name_taken": "The name \"{{id}}\" is already taken by another user.",
54
+ "rename.label": "Choose a new ID for your extension:",
55
+ "rename.placeholder": "e.g. my-ext-yourname",
56
+ "rename.hint": "Only lowercase letters, digits and hyphens. This will rename the local folder and update ext.yml.",
57
+ "rename.btn": "Rename & Publish",
58
+ "rename.renaming": "Renaming…",
53
59
  "pub.version.label": "Version",
54
60
 
55
61
  "publish.confirm.title": "Publish to the marketplace?",
@@ -89,6 +95,27 @@
89
95
  "pub.success": "Published successfully",
90
96
  "pub.done.close": "Done",
91
97
  "pub.retry": "Try again",
98
+ "pub.readme.placeholder": "## Overview\n\nDescribe what your extension does...",
99
+ "pub.screenshot.upload": "Upload screenshot",
100
+ "pub.screenshot.uploading": "Uploading…",
101
+ "pub.screenshot.section": "Screenshots",
102
+ "pub.screenshot.hint": "PNG / JPG / GIF, up to 5 MB each",
103
+ "pub.screenshot.err": "Upload failed: {{msg}}",
104
+ "pub.screenshot.err.type": "Only PNG / JPG / GIF are allowed",
105
+ "pub.screenshot.err.size": "File size must be under 5 MB",
106
+ "pub.readme.edit.title": "Edit README",
107
+ "readme.pane.editor": "Markdown editor",
108
+ "readme.pane.preview": "Preview",
109
+ "pub.readme.edit": "Edit",
110
+ "pub.readme.section": "Description",
111
+ "pub.readme.empty": "No description yet — click Edit to add one.",
112
+ "pub.readme.save.empty": "Description cannot be empty.",
113
+ "pub.readme.required": "Please add a description (README) before publishing.",
114
+ "pub.readme.required.skip": "Skip, publish anyway",
115
+ "pub.readme.required.edit": "Add description",
116
+ "pub.readme.save": "Save",
117
+ "pub.readme.saving": "Saving…",
118
+ "pub.readme.skip": "Skip",
92
119
  "extlist.section.cloud": "Published Extensions",
93
120
  "extlist.section.cloudHint": "Live on the marketplace",
94
121
  "extlist.section.local": "Local Extensions",
@@ -138,25 +165,6 @@
138
165
  "meta.saved": "Saved",
139
166
  "meta.save.err": "Save failed: {{msg}}",
140
167
 
141
- "entry.warn.title": "Access entry point not configured",
142
- "entry.warn.body": "This extension has UI panels but no entry point is set. Users won't know how to open it after installing.",
143
- "entry.warn.setup": "Set up entry point",
144
- "entry.warn.skip": "Ignore and continue",
145
- "entry.hint": "Choose where users can open this extension after installing it.",
146
- "entry.none": "This extension has no UI panels — no entry points needed.",
147
- "entry.unset": "Not configured — click Edit to set up.",
148
- "entry.slot.label": "Slot",
149
- "entry.kind.panel": "Panel",
150
- "entry.add": "Add entry point",
151
- "entry.remove": "Remove",
152
-
153
- "slot.sidebar.nav": "Sidebar nav",
154
- "slot.sidebar.nav.top": "Sidebar nav (top)",
155
- "slot.sidebar.nav.bottom": "Sidebar nav (bottom)",
156
- "slot.sidebar.footer": "Sidebar footer",
157
- "slot.main.workspace": "Main workspace",
158
- "slot.session.aside": "Session aside",
159
- "slot.settings.tabs": "Settings tab",
160
168
  "skills.section.cloud": "Cloud Skills",
161
169
  "skills.section.cloudHint": "Published to the platform",
162
170
  "skills.section.local": "Local Skills",
@@ -226,6 +234,12 @@
226
234
  "err.generic": "出错了:{{msg}}",
227
235
  "err.version_conflict": "版本 {{ver}} 已发布过,请在上方输入更高的版本号后重试。",
228
236
  "err.invalid_version": "版本号格式不对,请使用如 1.0.0 的格式。",
237
+ "err.name_taken": "扩展名「{{id}}」已被其他用户占用。",
238
+ "rename.label": "请为你的扩展选择一个新 ID:",
239
+ "rename.placeholder": "例如 my-ext-yourname",
240
+ "rename.hint": "只能使用小写字母、数字和连字符。",
241
+ "rename.btn": "重命名并发布",
242
+ "rename.renaming": "重命名中…",
229
243
  "pub.version.label": "版本号",
230
244
 
231
245
  "publish.confirm.title": "确定发布到市场?",
@@ -265,6 +279,27 @@
265
279
  "pub.success": "已发布成功",
266
280
  "pub.done.close": "完成",
267
281
  "pub.retry": "重试",
282
+ "pub.readme.placeholder": "## 简介\n\n描述你的扩展的功能…",
283
+ "pub.screenshot.upload": "上传截图",
284
+ "pub.screenshot.uploading": "上传中…",
285
+ "pub.screenshot.section": "截图",
286
+ "pub.screenshot.hint": "PNG / JPG / GIF,每张最大 5 MB",
287
+ "pub.screenshot.err": "上传失败:{{msg}}",
288
+ "pub.screenshot.err.type": "仅支持 PNG / JPG / GIF 格式",
289
+ "pub.screenshot.err.size": "文件大小不能超过 5 MB",
290
+ "pub.readme.edit.title": "编辑使用说明",
291
+ "readme.pane.editor": "Markdown 编写",
292
+ "readme.pane.preview": "预览",
293
+ "pub.readme.edit": "编辑",
294
+ "pub.readme.section": "使用说明",
295
+ "pub.readme.empty": "暂无说明 — 点击编辑添加。",
296
+ "pub.readme.save.empty": "使用说明不能为空。",
297
+ "pub.readme.required": "发布前请先填写使用说明。",
298
+ "pub.readme.required.skip": "忽略,继续发布",
299
+ "pub.readme.required.edit": "去填写",
300
+ "pub.readme.save": "保存",
301
+ "pub.readme.saving": "保存中…",
302
+ "pub.readme.skip": "跳过",
268
303
  "extlist.section.cloud": "已发布扩展",
269
304
  "extlist.section.cloudHint": "已上架到市场",
270
305
  "extlist.section.local": "本地扩展",
@@ -314,26 +349,6 @@
314
349
  "meta.saved": "已保存",
315
350
  "meta.save.err": "保存失败:{{msg}}",
316
351
 
317
- "entry.section": "访问入口",
318
- "entry.warn.title": "未配置访问入口",
319
- "entry.warn.body": "该扩展包含 UI 面板,但尚未设置访问入口。安装后用户可能不知道如何打开它。",
320
- "entry.warn.setup": "去设置",
321
- "entry.warn.skip": "忽略,继续发布",
322
- "entry.hint": "选择用户安装扩展后可以从哪里打开它。",
323
- "entry.none": "该扩展没有 UI 面板,无需配置访问入口。",
324
- "entry.unset": "尚未配置 — 点击编辑设置。",
325
- "entry.slot.label": "挂载位置",
326
- "entry.kind.panel": "面板",
327
- "entry.add": "添加入口",
328
- "entry.remove": "删除",
329
-
330
- "slot.sidebar.nav": "侧边栏导航",
331
- "slot.sidebar.nav.top": "侧边栏导航(顶部)",
332
- "slot.sidebar.nav.bottom": "侧边栏导航(底部)",
333
- "slot.sidebar.footer": "侧边栏底部",
334
- "slot.main.workspace": "主工作区",
335
- "slot.session.aside": "会话侧栏",
336
- "slot.settings.tabs": "设置页标签",
337
352
  "skills.section.cloud": "云端 Skills",
338
353
  "skills.section.cloudHint": "已发布到平台",
339
354
  "skills.section.local": "本地 Skills",
@@ -395,6 +410,22 @@
395
410
  return data;
396
411
  }
397
412
 
413
+ async function patchJson(path, body) {
414
+ const res = await fetch(api(path), {
415
+ method: "PATCH",
416
+ headers: { "Content-Type": "application/json" },
417
+ body: JSON.stringify(body || {}),
418
+ });
419
+ const data = await res.json();
420
+ if (!res.ok) {
421
+ const err = new Error(data.error || `Request failed (${res.status})`);
422
+ err.status = res.status;
423
+ err.data = data;
424
+ throw err;
425
+ }
426
+ return data;
427
+ }
428
+
398
429
  async function downloadPack(ext_id) {
399
430
  const res = await fetch(api("/pack"), {
400
431
  method: "POST",
@@ -560,42 +591,12 @@
560
591
  }
561
592
 
562
593
  // Unified publish flow: one modal that walks the creator from a release form
563
- // (version + notes) through progress → device binding (if needed) → done.
564
- // The creator owns the version; if it conflicts with the latest published
565
- // version they can bump it inline — the modal writes it back to ext.yml
566
- // before uploading. `prevVersionOrPromise` may be a value or a Promise.
594
+ // (version + notes) through progress → done.
595
+ // `prevVersionOrPromise` may be a value or a Promise.
567
596
  // Resolves true when a publish succeeded, false otherwise.
568
- function checkEntryPoints(ext) {
569
- const hasPanels = (ext.units || []).some((u) => u.kind === "panel");
570
- const hasEntries = Array.isArray(ext.entry_points) && ext.entry_points.length > 0;
571
- if (!hasPanels || hasEntries) return Promise.resolve(true);
572
597
 
598
+ function runPublishFlow(ext, prevVersionOrPromise, { onPublishDone } = {}) {
573
599
  return new Promise((resolve) => {
574
- const overlay = el("div", { class: "studio-modal-overlay" });
575
- const modal = el("div", { class: "studio-modal", style: "max-width:400px" });
576
- overlay.appendChild(modal);
577
- document.body.appendChild(overlay);
578
-
579
- function close(result) { document.body.removeChild(overlay); resolve(result); }
580
-
581
- modal.appendChild(el("h3", { class: "studio-modal-title", text: t("entry.warn.title") }));
582
- modal.appendChild(el("p", { style: "font-size:13px;color:var(--color-text-secondary);margin:0 0 20px", text: t("entry.warn.body") }));
583
-
584
- const footer = el("div", { class: "studio-modal-footer" });
585
- const skipBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("entry.warn.skip") });
586
- skipBtn.addEventListener("click", () => close(true));
587
- const setupBtn = el("button", { class: "studio-btn studio-btn-primary", text: t("entry.warn.setup") });
588
- setupBtn.addEventListener("click", () => { close(false); openMetaEditModal(ext, () => store.reload()); });
589
- footer.appendChild(skipBtn);
590
- footer.appendChild(setupBtn);
591
- modal.appendChild(footer);
592
- });
593
- }
594
-
595
- function runPublishFlow(ext, prevVersionOrPromise) {
596
- return checkEntryPoints(ext).then((proceed) => {
597
- if (!proceed) return false;
598
- return new Promise((resolve) => {
599
600
  let currentVersion = ext.version || "";
600
601
  let isUpdate = false;
601
602
 
@@ -708,15 +709,20 @@
708
709
  });
709
710
 
710
711
  if (data === null) { resetButtons(); return; }
712
+
713
+ // Name taken by another user — show inline rename form.
714
+ if (!data.ok && data.name_taken) {
715
+ showRenameForm(ext, data.error);
716
+ modal.close();
717
+ resolve(false);
718
+ return;
719
+ }
720
+
711
721
  if (!data.ok) throw new Error(data.error || "Publish failed");
712
722
 
713
723
  done = true;
714
- const successLabel = el("span", { class: "studio-pub-success", text: t("pub.success") });
715
- modal.footer.insertBefore(successLabel, modal.footer.firstChild);
716
- cancelBtn.style.display = "none";
717
- publishBtn.disabled = false;
718
- publishBtn.textContent = t("pub.btn.done");
719
- publishBtn.onclick = () => { modal.close(); resolve(true); };
724
+ modal.close();
725
+ resolve(true);
720
726
  } catch (e) {
721
727
  const msg = e.message || "";
722
728
  const isConflict = /must be greater than/i.test(msg);
@@ -734,7 +740,84 @@
734
740
  }
735
741
  }
736
742
  });
737
- }); }
743
+ }
744
+
745
+ // Show an inline modal that lets the creator pick a new extension ID when
746
+ // the current name is already taken by another user on the marketplace.
747
+ // On confirm: calls set_id to rename the local folder + ext.yml, then
748
+ // immediately re-triggers the full publish flow with the new id.
749
+ function showRenameForm(ext, serverError) {
750
+ const slugFormat = /^[a-z0-9][a-z0-9\-]*[a-z0-9]$/;
751
+
752
+ const errorMsg = el("p", { class: "studio-modal-status studio-modal-status-error",
753
+ text: t("err.name_taken", { id: ext.id }) });
754
+
755
+ const field = el("div", { class: "studio-field" });
756
+ field.appendChild(el("label", { class: "studio-label", text: t("rename.label") }));
757
+ const input = el("input", { class: "studio-input", type: "text",
758
+ value: ext.id, placeholder: t("rename.placeholder") });
759
+ const hint = el("p", { class: "studio-field-hint", text: t("rename.hint") });
760
+ field.appendChild(input);
761
+ field.appendChild(hint);
762
+
763
+ const status = el("p", { class: "studio-modal-status" });
764
+ status.style.display = "none";
765
+
766
+ let done = false;
767
+ const modal = openModal({
768
+ title: t("rename.btn"),
769
+ body: el("div", null, [field, errorMsg, status]),
770
+ buttons: [
771
+ { label: t("pub.btn.cancel"), keepOpen: false, onClick: () => {} },
772
+ { label: t("rename.btn"), primary: true, keepOpen: true, onClick: () => submit() },
773
+ ],
774
+ });
775
+
776
+ const confirmBtn = modal.footer.querySelector(".studio-btn-primary");
777
+ const cancelBtn = modal.footer.querySelector(".studio-btn:not(.studio-btn-primary)");
778
+
779
+ input.addEventListener("input", () => {
780
+ const val = input.value.trim();
781
+ const valid = val.length >= 2 && val.length <= 64 && slugFormat.test(val) && val !== ext.id;
782
+ confirmBtn.disabled = !valid;
783
+ input.classList.toggle("studio-input-error", !!val && !slugFormat.test(val));
784
+ status.style.display = "none";
785
+ });
786
+ // Disable confirm until user types a valid different id.
787
+ confirmBtn.disabled = true;
788
+
789
+ async function submit() {
790
+ if (done || confirmBtn.disabled) return;
791
+ const newId = input.value.trim();
792
+ confirmBtn.disabled = true;
793
+ confirmBtn.textContent = t("rename.renaming");
794
+ cancelBtn.disabled = true;
795
+ input.disabled = true;
796
+ status.style.display = "none";
797
+
798
+ try {
799
+ await postJson("/set_id", { ext_id: ext.id, new_id: newId });
800
+ done = true;
801
+ modal.close();
802
+ // Re-trigger the full publish flow with the renamed extension.
803
+ // Reload store first so the new id is visible.
804
+ await store.reload();
805
+ const renamed = store.extensions.find((e) => e.id === newId);
806
+ if (renamed) {
807
+ const ok = await runPublishFlow(renamed, null, { skipEntryCheck: true });
808
+ if (ok) store.reload();
809
+ }
810
+ } catch (e) {
811
+ status.textContent = t("err.generic", { msg: e.message });
812
+ status.style.display = "";
813
+ status.className = "studio-modal-status studio-modal-status-error";
814
+ confirmBtn.disabled = false;
815
+ confirmBtn.textContent = t("rename.btn");
816
+ cancelBtn.disabled = false;
817
+ input.disabled = false;
818
+ }
819
+ }
820
+ }
738
821
 
739
822
  // Both tabs care about "which local extension am I working on", so we keep a
740
823
  // tiny page-level store and let each tab subscribe to changes.
@@ -837,12 +920,6 @@
837
920
  if (box) { box.innerHTML = ""; }
838
921
  }
839
922
 
840
- function renderDetail() {
841
- const ext = store.selected();
842
- if (!ext) return;
843
- container.appendChild(buildExtInfoCard(ext, () => openMetaEditModal(ext, () => store.reload().then(rebuild))));
844
- }
845
-
846
923
  function rebuild() {
847
924
  if (!container) return;
848
925
  container.innerHTML = "";
@@ -853,8 +930,6 @@
853
930
  return;
854
931
  }
855
932
 
856
- renderDetail();
857
-
858
933
  const bar = el("div", { class: "studio-actions" });
859
934
  bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-verify-status", text: t("btn.recheck"), onclick: runVerify }));
860
935
  bar.appendChild(el("span", { class: "studio-verify-summary" }));
@@ -881,16 +956,6 @@
881
956
 
882
957
  // ── Publish tab ──────────────────────────────────────────────────────────
883
958
 
884
- const PANEL_SLOTS = [
885
- { value: "sidebar.nav", labelKey: "slot.sidebar.nav" },
886
- { value: "sidebar.nav.top", labelKey: "slot.sidebar.nav.top" },
887
- { value: "sidebar.nav.bottom", labelKey: "slot.sidebar.nav.bottom" },
888
- { value: "sidebar.footer", labelKey: "slot.sidebar.footer" },
889
- { value: "main.workspace", labelKey: "slot.main.workspace" },
890
- { value: "session.aside", labelKey: "slot.session.aside" },
891
- { value: "settings.tabs", labelKey: "slot.settings.tabs" },
892
- ];
893
-
894
959
  function buildExtInfoCard(ext, onEdit) {
895
960
  const card = el("div", { class: "studio-detail" });
896
961
 
@@ -908,23 +973,6 @@
908
973
  infoRow.appendChild(el("span", { class: "studio-info-value", text: ext.version || "—" }));
909
974
  card.appendChild(infoRow);
910
975
 
911
- const panelUnits = (ext.units || []).filter((u) => u.kind === "panel");
912
- if (panelUnits.length) {
913
- card.appendChild(el("div", { class: "studio-label studio-section-label", text: t("entry.section") }));
914
- const eps = Array.isArray(ext.entry_points) && ext.entry_points.length ? ext.entry_points : null;
915
- if (eps) {
916
- const chips = el("div", { class: "studio-units" });
917
- eps.forEach((ep) => {
918
- const slotDef = PANEL_SLOTS.find((s) => s.value === ep.slot);
919
- const label = slotDef ? t(slotDef.labelKey) : (ep.slot || ep.kind);
920
- chips.appendChild(el("span", { class: "studio-unit-chip", text: label }));
921
- });
922
- card.appendChild(chips);
923
- } else {
924
- card.appendChild(el("p", { class: "studio-empty", text: t("entry.unset") }));
925
- }
926
- }
927
-
928
976
  card.appendChild(el("div", { class: "studio-label studio-section-label", text: t("detail.units") }));
929
977
  if (!(ext.units || []).length) {
930
978
  card.appendChild(el("p", { class: "studio-empty", text: t("detail.noUnits") }));
@@ -937,10 +985,30 @@
937
985
  return card;
938
986
  }
939
987
 
940
- function openMetaEditModal(ext, onSaved) {
941
- let entries = Array.isArray(ext.entry_points) ? ext.entry_points.map((ep) => ({ ...ep })) : [];
942
- const panelUnits = (ext.units || []).filter((u) => u.kind === "panel");
988
+ function buildReadmeCard(extId, currentReadme, onEdit) {
989
+ const card = el("div", { class: "studio-detail studio-readme-card" });
943
990
 
991
+ const head = el("div", { class: "studio-detail-head" });
992
+ head.appendChild(el("h4", { class: "studio-detail-name", text: t("pub.readme.section") }));
993
+ const editBtn = el("button", { class: "studio-btn studio-btn-ghost studio-btn-sm", text: t("pub.readme.edit") });
994
+ editBtn.addEventListener("click", onEdit);
995
+ head.appendChild(editBtn);
996
+ card.appendChild(head);
997
+
998
+ if (currentReadme && currentReadme.trim()) {
999
+ const preview = el("div", { class: "studio-readme-preview extension-readme-body studio-readme-card-preview" });
1000
+ preview.innerHTML = typeof marked !== "undefined"
1001
+ ? marked.parse(currentReadme, { breaks: true, gfm: true })
1002
+ : `<pre>${currentReadme}</pre>`;
1003
+ card.appendChild(preview);
1004
+ } else {
1005
+ card.appendChild(el("p", { class: "studio-empty", text: t("pub.readme.empty") }));
1006
+ }
1007
+
1008
+ return card;
1009
+ }
1010
+
1011
+ function openMetaEditModal(ext, onSaved) {
944
1012
  const overlay = el("div", { class: "studio-modal-overlay" });
945
1013
  overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
946
1014
 
@@ -964,50 +1032,6 @@
964
1032
  descField.appendChild(descInput);
965
1033
  modal.appendChild(descField);
966
1034
 
967
- if (panelUnits.length) {
968
- const entryField = el("div", { class: "studio-field" });
969
- entryField.appendChild(el("label", { class: "studio-label", text: t("entry.section") }));
970
- entryField.appendChild(el("p", { class: "studio-hint", style: "margin:0 0 8px", text: t("entry.hint") }));
971
-
972
- const listEl = el("div", { class: "studio-entry-list" });
973
-
974
- function renderEntryList() {
975
- listEl.innerHTML = "";
976
- entries.forEach((ep, idx) => {
977
- const row = el("div", { class: "studio-entry-row" });
978
-
979
- const slotSel = el("select", { class: "studio-select" });
980
- PANEL_SLOTS.forEach(({ value, labelKey }) => {
981
- const opt = el("option", { value, text: t(labelKey) });
982
- if (ep.slot === value) opt.selected = true;
983
- slotSel.appendChild(opt);
984
- });
985
- if (!ep.slot) entries[idx].slot = PANEL_SLOTS[0].value;
986
- slotSel.addEventListener("change", () => { entries[idx].slot = slotSel.value; });
987
-
988
- const removeBtn = el("button", { class: "studio-btn studio-btn-ghost studio-btn-sm", text: "✕" });
989
- removeBtn.title = t("entry.remove");
990
- removeBtn.addEventListener("click", () => { entries.splice(idx, 1); renderEntryList(); });
991
-
992
- row.appendChild(slotSel);
993
- row.appendChild(removeBtn);
994
- listEl.appendChild(row);
995
- });
996
-
997
- const addBtn = el("button", { class: "studio-btn studio-btn-ghost studio-btn-sm", text: `+ ${t("entry.add")}` });
998
- addBtn.addEventListener("click", () => {
999
- const du = panelUnits[0];
1000
- entries.push({ panel_id: du ? du.id : "", slot: PANEL_SLOTS[0].value });
1001
- renderEntryList();
1002
- });
1003
- listEl.appendChild(addBtn);
1004
- }
1005
-
1006
- renderEntryList();
1007
- entryField.appendChild(listEl);
1008
- modal.appendChild(entryField);
1009
- }
1010
-
1011
1035
  const statusEl = el("span", { class: "studio-form-status" });
1012
1036
  const footer = el("div", { class: "studio-modal-footer" });
1013
1037
  const cancelBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("pub.btn.cancel"), onclick: close });
@@ -1018,12 +1042,10 @@
1018
1042
  saveBtn.textContent = t("meta.saving");
1019
1043
  statusEl.textContent = "";
1020
1044
  try {
1021
- const payload = entries.map((ep) => ({ unit_id: ep.panel_id, slot: ep.slot }));
1022
1045
  await postJson("/set_meta", {
1023
1046
  ext_id: ext.id,
1024
1047
  name: nameInput.value.trim(),
1025
1048
  description: descInput.value.trim(),
1026
- entry_points: payload,
1027
1049
  });
1028
1050
  close();
1029
1051
  if (onSaved) onSaved();
@@ -1040,6 +1062,199 @@
1040
1062
  modal.appendChild(footer);
1041
1063
  }
1042
1064
 
1065
+ // Standalone README + screenshot editor modal, reachable from the published
1066
+ // extension list at any time (not just after first publish).
1067
+
1068
+ // Prompt user to fill in README before publishing.
1069
+ // Returns a Promise that resolves when the user is ready to continue.
1070
+ // If readme is missing, shows a dialog: "Add description" opens the editor,
1071
+ // "Skip" proceeds without filling in.
1072
+ // Options:
1073
+ // onSaved(readme) — called after user saves the readme in the editor,
1074
+ // use this to trigger a UI refresh in the calling panel.
1075
+ function confirmReadme(ext, { onSaved } = {}) {
1076
+ if (ext.readme && ext.readme.trim()) return Promise.resolve();
1077
+ return new Promise((resolve) => {
1078
+ const overlay = el("div", { class: "studio-modal-overlay" });
1079
+ const box = el("div", { class: "studio-modal", style: "max-width:420px;padding:24px 28px 20px" });
1080
+ box.appendChild(el("h3", { class: "studio-modal-title", style: "margin-bottom:10px", text: t("pub.readme.section") }));
1081
+ box.appendChild(el("p", { style: "margin-bottom:20px;color:var(--color-text-secondary);font-size:14px", text: t("pub.readme.required") }));
1082
+ const footer = el("div", { class: "studio-modal-footer" });
1083
+ const skipBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("pub.readme.required.skip") });
1084
+ const editBtn = el("button", { class: "studio-btn studio-btn-primary", text: t("pub.readme.required.edit") });
1085
+ function close(goEdit) {
1086
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
1087
+ if (goEdit) {
1088
+ openReadmeEditModal(ext.id, ext.readme || "", (saved) => {
1089
+ ext.readme = saved;
1090
+ if (onSaved) onSaved(saved);
1091
+ resolve();
1092
+ });
1093
+ } else {
1094
+ resolve();
1095
+ }
1096
+ }
1097
+ skipBtn.addEventListener("click", () => close(false));
1098
+ editBtn.addEventListener("click", () => close(true));
1099
+ footer.appendChild(skipBtn);
1100
+ footer.appendChild(editBtn);
1101
+ box.appendChild(footer);
1102
+ overlay.appendChild(box);
1103
+ document.body.appendChild(overlay);
1104
+ });
1105
+ }
1106
+
1107
+ function openReadmeEditModal(extId, initialReadme, onSaved) {
1108
+ // Use a wider full-overlay modal with a split editor/preview layout.
1109
+ const overlay = el("div", { class: "studio-modal-overlay studio-readme-overlay" });
1110
+ const box = el("div", { class: "studio-modal studio-readme-modal" });
1111
+ overlay.appendChild(box);
1112
+ document.body.appendChild(overlay);
1113
+ function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
1114
+
1115
+ box.appendChild(el("h3", { class: "studio-modal-title", text: t("pub.readme.edit.title") }));
1116
+
1117
+ // Split pane: left = textarea, right = rendered preview
1118
+ const splitPane = el("div", { class: "studio-readme-split" });
1119
+
1120
+ const editorPane = el("div", { class: "studio-readme-pane" });
1121
+ editorPane.appendChild(el("div", { class: "studio-readme-pane-label", text: t("readme.pane.editor") }));
1122
+ const readmeArea = el("textarea", {
1123
+ class: "studio-textarea studio-readme-textarea",
1124
+ placeholder: t("pub.readme.placeholder"),
1125
+ });
1126
+ // Pre-fill with existing readme content
1127
+ if (initialReadme) {
1128
+ readmeArea.value = initialReadme;
1129
+ }
1130
+ editorPane.appendChild(readmeArea);
1131
+ splitPane.appendChild(editorPane);
1132
+
1133
+ const previewPane = el("div", { class: "studio-readme-pane" });
1134
+ previewPane.appendChild(el("div", { class: "studio-readme-pane-label", text: t("readme.pane.preview") }));
1135
+ const previewBody = el("div", { class: "studio-readme-preview extension-readme-body" });
1136
+ previewPane.appendChild(previewBody);
1137
+ splitPane.appendChild(previewPane);
1138
+
1139
+ box.appendChild(splitPane);
1140
+
1141
+ // Render helper
1142
+ function renderPreview() {
1143
+ const md = readmeArea.value;
1144
+ previewBody.innerHTML = md && typeof marked !== "undefined"
1145
+ ? marked.parse(md, { breaks: true, gfm: true })
1146
+ : (md ? `<pre>${md}</pre>` : "");
1147
+ }
1148
+
1149
+ // Live preview on input
1150
+ readmeArea.addEventListener("input", renderPreview);
1151
+
1152
+ // Initial preview if there's pre-filled content
1153
+ if (readmeArea.value) renderPreview();
1154
+
1155
+ // Screenshots section
1156
+ const screenshotsSection = el("div", { class: "studio-readme-screenshots" });
1157
+
1158
+ const screenshotRow = el("div", { class: "studio-readme-screenshots-row" });
1159
+
1160
+ const fileInput = el("input", { type: "file", accept: "image/png,image/jpeg,image/gif", style: "display:none" });
1161
+ screenshotsSection.appendChild(fileInput);
1162
+
1163
+ const uploadBtn = el("button", { class: "studio-btn studio-btn-ghost studio-btn-sm", text: t("pub.screenshot.upload") });
1164
+ uploadBtn.addEventListener("click", () => fileInput.click());
1165
+ screenshotRow.appendChild(uploadBtn);
1166
+ screenshotRow.appendChild(el("span", { class: "studio-readme-screenshots-hint", text: t("pub.screenshot.hint") }));
1167
+ screenshotsSection.appendChild(screenshotRow);
1168
+
1169
+ const screenshotErr = el("p", { class: "studio-modal-status studio-modal-status-error", style: "display:none;margin-top:6px" });
1170
+ screenshotsSection.appendChild(screenshotErr);
1171
+ fileInput.addEventListener("change", async () => {
1172
+ const file = fileInput.files[0];
1173
+ if (!file) return;
1174
+ fileInput.value = "";
1175
+ screenshotErr.style.display = "none";
1176
+ if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) {
1177
+ screenshotErr.textContent = t("pub.screenshot.err", { msg: t("pub.screenshot.err.type") });
1178
+ screenshotErr.style.display = "";
1179
+ return;
1180
+ }
1181
+ if (file.size > 5 * 1024 * 1024) {
1182
+ screenshotErr.textContent = t("pub.screenshot.err", { msg: t("pub.screenshot.err.size") });
1183
+ screenshotErr.style.display = "";
1184
+ return;
1185
+ }
1186
+ uploadBtn.disabled = true;
1187
+ uploadBtn.textContent = t("pub.screenshot.uploading");
1188
+ screenshotErr.style.display = "none";
1189
+ try {
1190
+ const form = new FormData();
1191
+ form.append("ext_id", extId);
1192
+ form.append("file", file);
1193
+ const res = await fetch("/api/ext/ext-studio/screenshot", { method: "POST", body: form });
1194
+ const json = await res.json();
1195
+ if (!res.ok || !json.ok) throw new Error(json.error || "upload failed");
1196
+ // Insert ![](url) at cursor position in the editor.
1197
+ // Ensure the image tag is on its own line by adding newlines if needed.
1198
+ const mdImg = `![](${json.url})`;
1199
+ const start = readmeArea.selectionStart;
1200
+ const end = readmeArea.selectionEnd;
1201
+ const before = readmeArea.value.slice(0, start);
1202
+ const after = readmeArea.value.slice(end);
1203
+ const prefix = before.length > 0 && !before.endsWith("\n") ? "\n" : "";
1204
+ const suffix = after.length > 0 && !after.startsWith("\n") ? "\n" : "";
1205
+ const inserted = prefix + mdImg + suffix;
1206
+ readmeArea.value = before + inserted + after;
1207
+ readmeArea.selectionStart = readmeArea.selectionEnd = start + inserted.length;
1208
+ readmeArea.focus();
1209
+ readmeArea.dispatchEvent(new Event("input"));
1210
+ } catch (e) {
1211
+ screenshotErr.textContent = t("pub.screenshot.err", { msg: e.message });
1212
+ screenshotErr.style.display = "";
1213
+ } finally {
1214
+ uploadBtn.disabled = false;
1215
+ uploadBtn.textContent = t("pub.screenshot.upload");
1216
+ }
1217
+ });
1218
+
1219
+ box.appendChild(screenshotsSection);
1220
+
1221
+ const statusEl = el("p", { class: "studio-modal-status", style: "display:none;margin-top:8px" });
1222
+ box.appendChild(statusEl);
1223
+
1224
+ const footer = el("div", { class: "studio-modal-footer" });
1225
+ const cancelBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("pub.btn.cancel") });
1226
+ cancelBtn.addEventListener("click", close);
1227
+ const saveBtn = el("button", { class: "studio-btn studio-btn-primary", text: t("pub.readme.save") });
1228
+ saveBtn.addEventListener("click", async () => {
1229
+ const readme = readmeArea.value.trim();
1230
+ if (!readme) {
1231
+ statusEl.className = "studio-modal-status studio-modal-status-error";
1232
+ statusEl.textContent = t("pub.readme.save.empty");
1233
+ statusEl.style.display = "";
1234
+ readmeArea.focus();
1235
+ return;
1236
+ }
1237
+ saveBtn.disabled = true;
1238
+ saveBtn.textContent = t("pub.readme.saving");
1239
+ cancelBtn.disabled = true;
1240
+ statusEl.style.display = "none";
1241
+ try {
1242
+ await patchJson("/readme", { ext_id: extId, readme });
1243
+ close();
1244
+ if (onSaved) onSaved(readme);
1245
+ } catch (e) {
1246
+ statusEl.textContent = t("err.generic", { msg: e.message });
1247
+ statusEl.style.display = "";
1248
+ saveBtn.disabled = false;
1249
+ saveBtn.textContent = t("pub.readme.save");
1250
+ cancelBtn.disabled = false;
1251
+ }
1252
+ });
1253
+ footer.appendChild(cancelBtn);
1254
+ footer.appendChild(saveBtn);
1255
+ box.appendChild(footer);
1256
+ }
1257
+
1043
1258
  function createPublishPanel() {
1044
1259
  let container = null;
1045
1260
  let unsub = null;
@@ -1052,13 +1267,14 @@
1052
1267
  async function doPublish() {
1053
1268
  const ext = store.selected();
1054
1269
  if (!ext) return;
1270
+ await confirmReadme(ext, { onSaved: () => rebuild() });
1055
1271
  const prevVersionPromise = getJson("/published")
1056
1272
  .then((data) => {
1057
1273
  const match = (data.extensions || []).find((e) => e.id === ext.id);
1058
1274
  return match ? match.version : null;
1059
1275
  })
1060
1276
  .catch(() => null);
1061
- const ok = await runPublishFlow(ext, prevVersionPromise);
1277
+ const ok = await runPublishFlow(ext, prevVersionPromise, { onPublishDone: loadPublished });
1062
1278
  if (ok) loadPublished();
1063
1279
  }
1064
1280
 
@@ -1134,7 +1350,16 @@
1134
1350
  const ext = store.selected();
1135
1351
  if (!ext) return;
1136
1352
 
1137
- container.appendChild(buildExtInfoCard(ext, () => openMetaEditModal(ext, () => store.reload().then(rebuild))));
1353
+ container.appendChild(buildExtInfoCard(
1354
+ ext,
1355
+ () => openMetaEditModal(ext, () => store.reload().then(rebuild))
1356
+ ));
1357
+
1358
+ container.appendChild(buildReadmeCard(
1359
+ ext.id,
1360
+ ext.readme,
1361
+ () => openReadmeEditModal(ext.id, ext.readme, () => store.reload().then(rebuild))
1362
+ ));
1138
1363
 
1139
1364
  const bar = el("div", { class: "studio-actions" });
1140
1365
  bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish }));
@@ -1233,7 +1458,7 @@
1233
1458
 
1234
1459
  const actions = el("div", { class: "studio-actions" });
1235
1460
  actions.appendChild(el("button", { class: "studio-btn studio-btn-primary", text: t("extlist.btn.iterate"), onclick: () => createExtension(t("extlist.iterate.seed", { id: ext.id })) }));
1236
- const un = el("button", { class: "studio-btn studio-btn-ghost studio-btn-sm", text: t("extlist.btn.unpublish") });
1461
+ const un = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.unpublish") });
1237
1462
  un.addEventListener("click", async () => {
1238
1463
  if (!window.confirm(t("extlist.unpublish.confirm", { id: ext.id }))) return;
1239
1464
  un.disabled = true;
@@ -1317,7 +1542,9 @@
1317
1542
  }
1318
1543
 
1319
1544
  async function doPublish(ext, prevVersion) {
1320
- const ok = await runPublishFlow(ext, prevVersion);
1545
+ await confirmReadme(ext, { onSaved: () => { reload().then(rebuild); } });
1546
+ const onDone = async () => { await reload(); rebuild(); };
1547
+ const ok = await runPublishFlow(ext, prevVersion, { onPublishDone: onDone });
1321
1548
  if (ok) { await reload(); rebuild(); }
1322
1549
  }
1323
1550
 
@@ -1723,13 +1950,25 @@
1723
1950
  .studio-section-label { margin-top: 10px; margin-bottom: 6px; }
1724
1951
  .studio-detail-head .studio-detail-name { margin: 0; }
1725
1952
  .studio-form-status { font-size: 12px; color: var(--color-text-tertiary); }
1726
- .studio-entry-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
1727
- .studio-entry-row { display: flex; align-items: stretch; gap: 8px; }
1728
- .studio-entry-arrow { font-size: 12px; color: var(--color-text-tertiary); flex-shrink: 0; }
1729
- .studio-entry-row .studio-select { flex: 1; width: auto; padding: 6px 28px 6px 8px; }
1730
- .studio-entry-row .studio-select:focus { border-color: var(--color-accent-primary); outline: none; }
1731
1953
  .studio-btn-sm { padding: 4px 10px; font-size: 12px; }
1732
- .studio-entry-row .studio-btn-sm { padding: 0 10px; }
1954
+ .studio-readme-overlay { align-items: center; justify-content: center; }
1955
+ .studio-readme-modal { width: min(1200px, calc(100vw - 48px)); height: calc(100vh - 80px); max-width: calc(100vw - 48px); border-radius: var(--radius-md, 8px); margin: 0; padding: 24px 28px 20px; display: flex; flex-direction: column; }
1956
+ .studio-readme-modal .studio-modal-title { margin-bottom: 16px; flex-shrink: 0; }
1957
+ .studio-readme-split { display: flex; gap: 14px; flex: 1; min-height: 0; margin-bottom: 18px; }
1958
+ .studio-readme-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
1959
+ .studio-readme-pane-label { font-size: 12px; font-weight: 600; color: var(--color-text-muted); letter-spacing: 0.02em; padding-bottom: 6px; border-bottom: 1px solid var(--color-border-primary); margin-bottom: 8px; }
1960
+ .studio-readme-textarea { flex: 1; resize: none; font-family: monospace; font-size: 12.5px; line-height: 1.65; border-radius: var(--radius-sm); }
1961
+ .studio-readme-preview { flex: 1; overflow-y: auto; overflow-x: hidden; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 12px 16px; background: var(--color-bg-secondary); font-size: 13px; line-height: 1.75; }
1962
+ .studio-readme-preview > *:first-child { margin-top: 0; }
1963
+ .studio-readme-preview img { max-width: 100%; height: auto; display: block; border-radius: var(--radius-sm); }
1964
+ .studio-readme-screenshots { border-top: 1px solid var(--color-border-primary); padding-top: 14px; margin-bottom: 6px; }
1965
+ .studio-readme-screenshots-row { display: flex; align-items: center; gap: 10px; }
1966
+ .studio-readme-screenshots-hint { font-size: 11px; color: var(--color-text-muted); }
1967
+ .studio-readme-card { margin-top: 8px; }
1968
+ .studio-readme-card-preview { max-height: 200px; overflow-y: auto; font-size: 13px; }
1969
+ .extension-readme-body img { max-width: min(800px, 100%); height: auto; }
1970
+ .studio-link-btn { background: none; border: none; padding: 0; font-size: 12px; color: var(--color-accent-primary); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
1971
+ .studio-link-btn:hover { color: var(--color-accent-hover); }
1733
1972
  `;
1734
1973
  document.head.appendChild(style);
1735
1974
  })();