pandorga 1.4.2 → 1.4.4

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.
data/studio/index.html CHANGED
@@ -18,8 +18,8 @@
18
18
  rel="stylesheet"
19
19
  href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css"
20
20
  />
21
- <script type="module" crossorigin src="/studio/assets/index-pL2vB_T0.js"></script>
22
- <link rel="stylesheet" crossorigin href="/studio/assets/index-BQdNT4zp.css">
21
+ <script type="module" crossorigin src="/studio/assets/index-Cf9OPjQU.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/studio/assets/index-Cd1mlY_H.css">
23
23
  </head>
24
24
  <body>
25
25
  <div id="app"></div>
@@ -73,4 +73,10 @@ export const studioApi = {
73
73
  method: "POST",
74
74
  body: JSON.stringify(payload),
75
75
  }),
76
+ /** Content-pipeline run for a commit SHA (production). */
77
+ pipelineStatus: (sha, workflow = null) => {
78
+ const params = new URLSearchParams({ sha: String(sha || "") });
79
+ if (workflow) params.set("workflow", String(workflow));
80
+ return api(`pipeline-status?${params.toString()}`);
81
+ },
76
82
  };
@@ -202,6 +202,19 @@ const state = {
202
202
  navMenuOpen: false,
203
203
  /** Theme menu (light / dark / system) in the icon toolbar. */
204
204
  themeMenuOpen: false,
205
+ /**
206
+ * Content publish indicator (local Export animation or production Actions).
207
+ * state: idle | syncing | awaiting_run | running | ok | error | stalled | unconfigured
208
+ */
209
+ pipeline: {
210
+ state: "idle",
211
+ sha: "",
212
+ runUrl: "",
213
+ message: "",
214
+ pollGen: 0,
215
+ },
216
+ /** Local Export button in flight. */
217
+ reexporting: false,
205
218
  /** Per-collection list query (session memory). */
206
219
  listQueryByCollection: Object.create(null),
207
220
  /** Per-collection sort id (session + localStorage). */
@@ -432,6 +445,7 @@ function mediaOpts() {
432
445
  captureError,
433
446
  clearError,
434
447
  goHome,
448
+ onCommitted: (commitSha) => armPipelineWatch(commitSha),
435
449
  };
436
450
  }
437
451
 
@@ -825,6 +839,192 @@ function isLocalDevHost() {
825
839
  return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
826
840
  }
827
841
 
842
+ function sleep(ms) {
843
+ return new Promise((resolve) => setTimeout(resolve, ms));
844
+ }
845
+
846
+ function pipelineChipMeta(pipelineState) {
847
+ switch (pipelineState) {
848
+ case "syncing":
849
+ case "running":
850
+ return {
851
+ label: "Exporting…",
852
+ icon: "progress_activity",
853
+ className: "pipeline-chip pipeline-running",
854
+ spinning: true,
855
+ title: "Content export is running",
856
+ };
857
+ case "awaiting_run":
858
+ return {
859
+ label: "Waiting…",
860
+ icon: "schedule",
861
+ className: "pipeline-chip pipeline-awaiting",
862
+ spinning: false,
863
+ title: "Waiting for the content pipeline run",
864
+ };
865
+ case "ok":
866
+ return {
867
+ label: "Live",
868
+ icon: "check_circle",
869
+ className: "pipeline-chip pipeline-ok",
870
+ spinning: false,
871
+ title: "Content pipeline succeeded for this commit",
872
+ };
873
+ case "error":
874
+ return {
875
+ label: "Failed",
876
+ icon: "error",
877
+ className: "pipeline-chip pipeline-error",
878
+ spinning: false,
879
+ title: state.pipeline.message || "Content pipeline failed",
880
+ };
881
+ case "stalled":
882
+ return {
883
+ label: "No run",
884
+ icon: "hourglass_disabled",
885
+ className: "pipeline-chip pipeline-stalled",
886
+ spinning: false,
887
+ title: "No pipeline run appeared for this commit yet",
888
+ };
889
+ case "unconfigured":
890
+ return {
891
+ label: "Pipeline",
892
+ icon: "cloud_off",
893
+ className: "pipeline-chip pipeline-idle",
894
+ spinning: false,
895
+ title: "STUDIO_CONTENT_WORKFLOW is not set on the Studio API",
896
+ };
897
+ default:
898
+ return {
899
+ label: "Pipeline",
900
+ icon: "cloud",
901
+ className: "pipeline-chip pipeline-idle",
902
+ spinning: false,
903
+ title: "Content pipeline status",
904
+ };
905
+ }
906
+ }
907
+
908
+ function renderPipelineChip() {
909
+ const meta = pipelineChipMeta(state.pipeline.state);
910
+ const runUrl = state.pipeline.runUrl;
911
+ const kids = [
912
+ el("span", {
913
+ className:
914
+ "material-symbols-outlined" + (meta.spinning ? " studio-icon-spin" : ""),
915
+ text: meta.icon,
916
+ "aria-hidden": "true",
917
+ }),
918
+ el("span", { className: "btn-label", text: meta.label }),
919
+ ];
920
+ return el(
921
+ "button",
922
+ {
923
+ className: `btn btn-tool ${meta.className}`,
924
+ type: "button",
925
+ title: meta.title,
926
+ "aria-label": meta.label,
927
+ role: "status",
928
+ "data-studio-action": "pipeline-status",
929
+ disabled: !runUrl,
930
+ onClick: () => {
931
+ if (!runUrl) return;
932
+ try {
933
+ window.open(runUrl, "_blank", "noopener,noreferrer");
934
+ } catch {
935
+ /* ignore */
936
+ }
937
+ },
938
+ },
939
+ kids
940
+ );
941
+ }
942
+
943
+ /**
944
+ * After a production Save, poll Actions for the content-pipeline run on this SHA.
945
+ */
946
+ function armPipelineWatch(commitSha) {
947
+ const sha = String(commitSha || "").trim().toLowerCase();
948
+ if (!sha || isLocalDevHost()) return;
949
+ const gen = ++state.pipeline.pollGen;
950
+ state.pipeline.state = "awaiting_run";
951
+ state.pipeline.sha = sha;
952
+ state.pipeline.runUrl = "";
953
+ state.pipeline.message = "";
954
+ renderShell();
955
+ void pollPipelineStatus(gen, sha);
956
+ }
957
+
958
+ async function pollPipelineStatus(gen, sha) {
959
+ const started = Date.now();
960
+ const STALL_MS = 10 * 60 * 1000;
961
+ const MAX_MS = 30 * 60 * 1000;
962
+ let softFails = 0;
963
+
964
+ while (gen === state.pipeline.pollGen) {
965
+ const elapsed = Date.now() - started;
966
+ if (elapsed > MAX_MS) {
967
+ if (state.pipeline.state === "running" || state.pipeline.state === "awaiting_run") {
968
+ state.pipeline.state = "stalled";
969
+ state.pipeline.message = "Timed out waiting for the content pipeline.";
970
+ renderShell();
971
+ }
972
+ return;
973
+ }
974
+
975
+ try {
976
+ const data = await studioApi.pipelineStatus(sha);
977
+ if (gen !== state.pipeline.pollGen) return;
978
+ softFails = 0;
979
+ state.pipeline.runUrl = data.run?.html_url || state.pipeline.runUrl || "";
980
+ state.pipeline.message = "";
981
+
982
+ if (data.state === "unconfigured") {
983
+ state.pipeline.state = "unconfigured";
984
+ renderShell();
985
+ return;
986
+ }
987
+ if (data.state === "ok" || data.state === "error") {
988
+ state.pipeline.state = data.state;
989
+ if (data.state === "error") {
990
+ state.pipeline.message = "Content pipeline failed for this commit.";
991
+ }
992
+ renderShell();
993
+ return;
994
+ }
995
+ if (data.state === "running") {
996
+ state.pipeline.state = "running";
997
+ } else if (data.state === "awaiting_run") {
998
+ if (elapsed > STALL_MS) {
999
+ state.pipeline.state = "stalled";
1000
+ renderShell();
1001
+ return;
1002
+ }
1003
+ state.pipeline.state = "awaiting_run";
1004
+ }
1005
+ renderShell();
1006
+ } catch (err) {
1007
+ if (gen !== state.pipeline.pollGen) return;
1008
+ softFails += 1;
1009
+ const code = err?.code || "";
1010
+ if (code === "actions_forbidden" || code === "github_unconfigured") {
1011
+ state.pipeline.state = "error";
1012
+ state.pipeline.message = String(err.message || err);
1013
+ renderShell();
1014
+ return;
1015
+ }
1016
+ if (softFails >= 5) {
1017
+ state.pipeline.state = "error";
1018
+ state.pipeline.message = String(err.message || err);
1019
+ renderShell();
1020
+ return;
1021
+ }
1022
+ }
1023
+
1024
+ await sleep(elapsed < 60_000 ? 2500 : 7000);
1025
+ }
1026
+ }
1027
+
828
1028
  function collectionAllowsCreate(col = state.collection) {
829
1029
  if (!col || col.type !== "collection") return false;
830
1030
  return col.operations?.create !== false;
@@ -956,17 +1156,28 @@ function renderStudioToolbar({ brand = null, menuToggle = null } = {}) {
956
1156
  );
957
1157
  }
958
1158
  if (isLocalDevHost()) {
1159
+ const exportBusy = state.reexporting || state.pipeline.state === "syncing";
959
1160
  actions.push(
960
1161
  toolbarActionButton({
961
- className: "btn btn-tool btn-reexport",
962
- title: "Export content JSON locally and reload",
963
- ariaLabel: "Export locally",
1162
+ className:
1163
+ "btn btn-tool btn-reexport" +
1164
+ (exportBusy ? " pipeline-running" : "") +
1165
+ (state.pipeline.state === "ok" && !exportBusy ? " pipeline-ok" : "") +
1166
+ (state.pipeline.state === "error" && !exportBusy ? " pipeline-error" : ""),
1167
+ title: exportBusy
1168
+ ? "Exporting content JSON locally"
1169
+ : "Export content JSON locally and reload",
1170
+ ariaLabel: exportBusy ? "Exporting locally" : "Export locally",
964
1171
  action: "reexport",
1172
+ disabled: exportBusy,
965
1173
  onClick: () => reexportLocalContent(),
966
- icon: "sync",
967
- label: "Export",
1174
+ icon: exportBusy ? "progress_activity" : "sync",
1175
+ label: exportBusy ? "Exporting…" : "Export",
1176
+ spinning: exportBusy,
968
1177
  })
969
1178
  );
1179
+ } else if (state.pipeline.state !== "idle") {
1180
+ actions.push(renderPipelineChip());
970
1181
  }
971
1182
 
972
1183
  const kids = [
@@ -3782,6 +3993,10 @@ function discardChanges() {
3782
3993
  }
3783
3994
 
3784
3995
  async function reexportLocalContent() {
3996
+ if (state.reexporting) return;
3997
+ state.reexporting = true;
3998
+ state.pipeline.state = "syncing";
3999
+ state.pipeline.message = "";
3785
4000
  state.status = "Reexporting…";
3786
4001
  clearError();
3787
4002
  renderShell();
@@ -3799,6 +4014,7 @@ async function reexportLocalContent() {
3799
4014
  if (!res.ok || !data.ok) {
3800
4015
  throw new Error((data && data.error) || `Export failed: ${res.status}`);
3801
4016
  }
4017
+ state.pipeline.state = "ok";
3802
4018
  state.status = "Reexported — reloading…";
3803
4019
  renderShell();
3804
4020
  setTimeout(() => {
@@ -3811,8 +4027,12 @@ async function reexportLocalContent() {
3811
4027
  }
3812
4028
  }, 200);
3813
4029
  } catch (err) {
4030
+ state.pipeline.state = "error";
4031
+ state.pipeline.message = String(err.message || err);
3814
4032
  captureError(err);
3815
4033
  renderShell();
4034
+ } finally {
4035
+ state.reexporting = false;
3816
4036
  }
3817
4037
  }
3818
4038
 
@@ -3872,7 +4092,7 @@ async function createNewEntry() {
3872
4092
  }
3873
4093
  if (exists) throw new Error(`${path} already exists`);
3874
4094
 
3875
- await studioApi.putFile({
4095
+ const created = await studioApi.putFile({
3876
4096
  path,
3877
4097
  content,
3878
4098
  sha: null,
@@ -3880,6 +4100,7 @@ async function createNewEntry() {
3880
4100
  });
3881
4101
  invalidateTreeCache(col.name);
3882
4102
  state.status = `Created ${path}`;
4103
+ armPipelineWatch(created?.commit);
3883
4104
  await selectCollection(col);
3884
4105
  await openFile(path);
3885
4106
  } catch (err) {
@@ -3904,7 +4125,7 @@ async function deleteEntry(path, sha = null) {
3904
4125
  const data = await studioApi.getFile(path);
3905
4126
  fileSha = data.sha;
3906
4127
  }
3907
- await studioApi.deleteFile({
4128
+ const deleted = await studioApi.deleteFile({
3908
4129
  path,
3909
4130
  sha: fileSha,
3910
4131
  message: `studio: delete ${path}`,
@@ -3920,6 +4141,7 @@ async function deleteEntry(path, sha = null) {
3920
4141
  state.body = "";
3921
4142
  }
3922
4143
  state.status = `Deleted ${path}`;
4144
+ armPipelineWatch(deleted?.commit);
3923
4145
  if (col) await selectCollection(col);
3924
4146
  else renderShell();
3925
4147
  } catch (err) {
@@ -3999,6 +4221,7 @@ async function saveCurrent() {
3999
4221
  captureBaseline();
4000
4222
  // List thumbs/titles may have changed — drop session tree for this collection.
4001
4223
  if (col?.name) invalidateTreeCache(col.name);
4224
+ armPipelineWatch(result?.commit);
4002
4225
  } catch (err) {
4003
4226
  captureError(err);
4004
4227
  } finally {
@@ -544,13 +544,16 @@ async function deleteMediaFile(item, opts) {
544
544
  const data = await api.getFile(path);
545
545
  sha = data.sha;
546
546
  }
547
- await api.deleteFile({
547
+ const deleted = await api.deleteFile({
548
548
  path,
549
549
  sha,
550
550
  message: `studio: delete ${path}`,
551
551
  });
552
552
  state.mediaBusy = false;
553
553
  state.status = `Deleted ${path}`;
554
+ if (typeof opts.onCommitted === "function") {
555
+ opts.onCommitted(deleted?.commit);
556
+ }
554
557
  await openMediaDir(state.mediaPath, opts);
555
558
  } catch (err) {
556
559
  state.mediaBusy = false;
@@ -600,12 +603,15 @@ async function createFolder(opts) {
600
603
  state.status = `Creating ${name}…`;
601
604
  render();
602
605
  try {
603
- await api.createMediaFolder({
606
+ const created = await api.createMediaFolder({
604
607
  path,
605
608
  message: `studio: mkdir ${path}`,
606
609
  });
607
610
  state.mediaBusy = false;
608
611
  state.status = `Created ${toPublicPath(path) || path}`;
612
+ if (typeof opts.onCommitted === "function") {
613
+ opts.onCommitted(created?.commit);
614
+ }
609
615
  await openMediaDir(state.mediaPath, opts);
610
616
  } catch (err) {
611
617
  state.mediaBusy = false;
@@ -619,6 +625,7 @@ async function uploadFiles(files, opts) {
619
625
  clearError();
620
626
  const dir = state.mediaPath || MEDIA_ROOT;
621
627
  let ok = 0;
628
+ let lastCommit = null;
622
629
  state.mediaBusy = true;
623
630
 
624
631
  for (const file of files) {
@@ -643,6 +650,7 @@ async function uploadFiles(files, opts) {
643
650
  });
644
651
  ok += 1;
645
652
  state.status = `Uploaded ${result.public_path || toPublicPath(path)}`;
653
+ lastCommit = result?.commit || lastCommit;
646
654
  } catch (err) {
647
655
  captureError(err);
648
656
  state.mediaBusy = false;
@@ -653,5 +661,8 @@ async function uploadFiles(files, opts) {
653
661
 
654
662
  state.mediaBusy = false;
655
663
  if (ok) state.status = ok === 1 ? state.status : `Uploaded ${ok} files`;
664
+ if (ok && lastCommit && typeof opts.onCommitted === "function") {
665
+ opts.onCommitted(lastCommit);
666
+ }
656
667
  await openMediaDir(dir, opts);
657
668
  }
@@ -719,6 +719,67 @@ button.studio-brand-home.studio-brand-toolbar[aria-current="page"] {
719
719
  background: color-mix(in srgb, #746f30 45%, var(--panel));
720
720
  }
721
721
 
722
+ /* Content-pipeline status chip (production) / Export sync colors (local). */
723
+ .pipeline-chip {
724
+ cursor: default;
725
+ }
726
+
727
+ .pipeline-chip:not(:disabled) {
728
+ cursor: pointer;
729
+ }
730
+
731
+ .pipeline-awaiting,
732
+ .btn-reexport.pipeline-running,
733
+ .pipeline-running {
734
+ color: #5c4b00;
735
+ border-color: color-mix(in srgb, #e0a100 55%, var(--outline-variant));
736
+ background: color-mix(in srgb, #f5c84c 50%, var(--background));
737
+ }
738
+
739
+ .pipeline-ok,
740
+ .btn-reexport.pipeline-ok {
741
+ color: #14532d;
742
+ border-color: color-mix(in srgb, #22c55e 50%, var(--outline-variant));
743
+ background: color-mix(in srgb, #86efac 45%, var(--background));
744
+ }
745
+
746
+ .pipeline-error,
747
+ .btn-reexport.pipeline-error,
748
+ .pipeline-stalled {
749
+ color: #7f1d1d;
750
+ border-color: color-mix(in srgb, #ef4444 50%, var(--outline-variant));
751
+ background: color-mix(in srgb, #fca5a5 40%, var(--background));
752
+ }
753
+
754
+ .pipeline-idle {
755
+ color: var(--on-surface-variant);
756
+ border-color: var(--outline-variant);
757
+ background: var(--surface-container);
758
+ }
759
+
760
+ [data-theme="dark"] .pipeline-awaiting,
761
+ [data-theme="dark"] .pipeline-running,
762
+ [data-theme="dark"] .btn-reexport.pipeline-running {
763
+ color: #f5e6a3;
764
+ border-color: color-mix(in srgb, #e0a100 45%, var(--outline-variant));
765
+ background: color-mix(in srgb, #746f30 50%, var(--panel));
766
+ }
767
+
768
+ [data-theme="dark"] .pipeline-ok,
769
+ [data-theme="dark"] .btn-reexport.pipeline-ok {
770
+ color: #bbf7d0;
771
+ border-color: color-mix(in srgb, #22c55e 45%, var(--outline-variant));
772
+ background: color-mix(in srgb, #14532d 55%, var(--panel));
773
+ }
774
+
775
+ [data-theme="dark"] .pipeline-error,
776
+ [data-theme="dark"] .btn-reexport.pipeline-error,
777
+ [data-theme="dark"] .pipeline-stalled {
778
+ color: #fecaca;
779
+ border-color: color-mix(in srgb, #ef4444 45%, var(--outline-variant));
780
+ background: color-mix(in srgb, #7f1d1d 55%, var(--panel));
781
+ }
782
+
722
783
  .studio-icon-bar .btn-icon {
723
784
  width: var(--studio-list-control-height);
724
785
  min-width: var(--studio-list-control-height);
@@ -1915,6 +1976,7 @@ button.studio-brand-home.studio-brand-toolbar[aria-current="page"] {
1915
1976
  }
1916
1977
 
1917
1978
  .editor-format-toolbar {
1979
+ --editor-tool-size: 2.35rem;
1918
1980
  display: flex;
1919
1981
  flex-wrap: wrap;
1920
1982
  align-items: center;
@@ -1928,10 +1990,15 @@ button.studio-brand-home.studio-brand-toolbar[aria-current="page"] {
1928
1990
  display: inline-flex;
1929
1991
  align-items: center;
1930
1992
  justify-content: center;
1931
- min-width: 2rem;
1932
- height: 2rem;
1993
+ box-sizing: border-box;
1994
+ width: var(--editor-tool-size);
1995
+ min-width: var(--editor-tool-size);
1996
+ max-width: var(--editor-tool-size);
1997
+ height: var(--editor-tool-size);
1998
+ min-height: var(--editor-tool-size);
1999
+ max-height: var(--editor-tool-size);
1933
2000
  margin: 0;
1934
- padding: 0 0.35rem;
2001
+ padding: 0;
1935
2002
  border: 0;
1936
2003
  border-radius: 0;
1937
2004
  background: transparent;
@@ -1940,6 +2007,8 @@ button.studio-brand-home.studio-brand-toolbar[aria-current="page"] {
1940
2007
  font: inherit;
1941
2008
  font-size: 0.82rem;
1942
2009
  font-weight: 600;
2010
+ line-height: 1;
2011
+ overflow: hidden;
1943
2012
  }
1944
2013
 
1945
2014
  .editor-toolbar-btn:hover {
@@ -1960,10 +2029,15 @@ button.studio-brand-home.studio-brand-toolbar[aria-current="page"] {
1960
2029
  }
1961
2030
 
1962
2031
  .editor-toolbar-select {
2032
+ box-sizing: border-box;
2033
+ height: var(--editor-tool-size);
2034
+ min-height: var(--editor-tool-size);
2035
+ max-height: var(--editor-tool-size);
1963
2036
  border: 1px solid color-mix(in srgb, var(--outline-variant) 70%, transparent);
1964
2037
  background: var(--background);
1965
- padding: 0.25rem 0.4rem;
2038
+ padding: 0 0.4rem;
1966
2039
  font-size: 0.82rem;
2040
+ line-height: 1;
1967
2041
  max-width: 8.5rem;
1968
2042
  }
1969
2043
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pandorga
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.2
4
+ version: 1.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -449,8 +449,8 @@ files:
449
449
  - studio-app/src/preview/render.js
450
450
  - studio-app/src/styles/studio.css
451
451
  - studio-app/vite.config.js
452
- - studio/assets/index-BQdNT4zp.css
453
- - studio/assets/index-pL2vB_T0.js
452
+ - studio/assets/index-Cd1mlY_H.css
453
+ - studio/assets/index-Cf9OPjQU.js
454
454
  - studio/index.html
455
455
  - studio/schema.yml
456
456
  - templates/articles/README.md