@orion-studios/payload-studio 0.4.0-beta.0 → 0.4.0-beta.2

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/README.md CHANGED
@@ -13,6 +13,9 @@ npm install @orion-studios/payload-studio
13
13
  - `@orion-studios/payload-studio/admin`
14
14
  - `@orion-studios/payload-studio/admin/client`
15
15
  - `@orion-studios/payload-studio/admin.css`
16
+ - `@orion-studios/payload-studio/admin-app`
17
+ - `@orion-studios/payload-studio/admin-app/client`
18
+ - `@orion-studios/payload-studio/admin-app/styles.css`
16
19
  - `@orion-studios/payload-studio/blocks`
17
20
  - `@orion-studios/payload-studio/nextjs`
18
21
  - `@orion-studios/payload-studio/studio`
@@ -35,6 +38,12 @@ import { BuilderPageEditor } from '@orion-studios/payload-studio/studio-pages/cl
35
38
  import '@orion-studios/payload-studio/studio-pages/builder.css'
36
39
  ```
37
40
 
41
+ ```ts
42
+ import { AdminPage } from '@orion-studios/payload-studio/admin-app'
43
+ import { AdminShellClient } from '@orion-studios/payload-studio/admin-app/client'
44
+ import '@orion-studios/payload-studio/admin-app/styles.css'
45
+ ```
46
+
38
47
  ## Build
39
48
 
40
49
  ```bash
@@ -115,3 +115,8 @@
115
115
  min-width: 0;
116
116
  padding: 1.15rem 1.2rem 2.6rem;
117
117
  }
118
+
119
+ .orion-admin-page {
120
+ max-width: none;
121
+ width: 100%;
122
+ }
@@ -112,8 +112,13 @@ body {
112
112
  .features {
113
113
  border-radius: var(--orion-studio-radius-lg);
114
114
  padding: 1.25rem;
115
- background: #ffffff;
115
+ background: linear-gradient(135deg, #124a37 0%, #1f684f 100%);
116
116
  border: 1px solid var(--orion-studio-border);
117
+ color: #fff;
118
+ }
119
+
120
+ .features .inner {
121
+ padding: 1.8rem;
117
122
  }
118
123
 
119
124
  .feature-grid {
@@ -124,12 +129,20 @@ body {
124
129
  }
125
130
 
126
131
  .feature-item {
127
- background: #fff;
128
- border: 1px solid rgba(19, 33, 28, 0.1);
132
+ background: rgba(255, 255, 255, 0.1);
133
+ border: 1px solid rgba(255, 255, 255, 0.2);
129
134
  border-radius: 14px;
130
135
  padding: 0.9rem;
131
136
  }
132
137
 
138
+ .feature-item h3 {
139
+ color: #fff;
140
+ }
141
+
142
+ .feature-item p {
143
+ color: rgba(255, 255, 255, 0.88);
144
+ }
145
+
133
146
  .feature-icon {
134
147
  width: 44px;
135
148
  height: 44px;
@@ -518,6 +518,37 @@ function parseColor(value, fallback) {
518
518
  }
519
519
  return fallback;
520
520
  }
521
+ function getRelationID(value) {
522
+ if (typeof value === "number" || typeof value === "string") {
523
+ return value;
524
+ }
525
+ if (!value || typeof value !== "object") {
526
+ return null;
527
+ }
528
+ if ("id" in value) {
529
+ const id = value.id;
530
+ if (typeof id === "number" || typeof id === "string") {
531
+ return id;
532
+ }
533
+ }
534
+ return null;
535
+ }
536
+ function extractUploadedMedia(value) {
537
+ const candidate = value && typeof value === "object" && "doc" in value ? value.doc : value;
538
+ if (!candidate || typeof candidate !== "object") {
539
+ return null;
540
+ }
541
+ const id = getRelationID(candidate);
542
+ if (id === null) {
543
+ return null;
544
+ }
545
+ return {
546
+ alt: typeof candidate.alt === "string" ? candidate.alt : "",
547
+ filename: typeof candidate.filename === "string" ? candidate.filename : "",
548
+ id,
549
+ url: typeof candidate.url === "string" ? candidate.url : ""
550
+ };
551
+ }
521
552
  function InlineText({
522
553
  as = "p",
523
554
  className,
@@ -807,6 +838,13 @@ function BuilderPageEditor({ initialDoc, pageID }) {
807
838
  const [selectedIndex, setSelectedIndex] = (0, import_react.useState)(null);
808
839
  const [dragIndex, setDragIndex] = (0, import_react.useState)(null);
809
840
  const [sidebarOpen, setSidebarOpen] = (0, import_react.useState)(true);
841
+ const [savingStatus, setSavingStatus] = (0, import_react.useState)(null);
842
+ const [saveMessage, setSaveMessage] = (0, import_react.useState)("");
843
+ const [saveError, setSaveError] = (0, import_react.useState)("");
844
+ const [uploadingTarget, setUploadingTarget] = (0, import_react.useState)(null);
845
+ const [uploadError, setUploadError] = (0, import_react.useState)("");
846
+ const [uploadMessage, setUploadMessage] = (0, import_react.useState)("");
847
+ const [uploadAltText, setUploadAltText] = (0, import_react.useState)("");
810
848
  const selectedBlock = (0, import_react.useMemo)(
811
849
  () => selectedIndex !== null ? layout[selectedIndex] : null,
812
850
  [layout, selectedIndex]
@@ -873,6 +911,76 @@ function BuilderPageEditor({ initialDoc, pageID }) {
873
911
  const currentItems = Array.isArray(selectedBlock[fieldName]) ? selectedBlock[fieldName] : [];
874
912
  updateSelectedArray(fieldName, [...currentItems, item]);
875
913
  };
914
+ const uploadMediaForSelected = async (target, file) => {
915
+ if (selectedIndex === null) {
916
+ setUploadError("Select a section first.");
917
+ return;
918
+ }
919
+ setUploadingTarget(target);
920
+ setUploadError("");
921
+ setUploadMessage("");
922
+ try {
923
+ const formData = new FormData();
924
+ const fallbackAlt = file.name.replace(/\.[^/.]+$/, "").trim();
925
+ const resolvedAlt = uploadAltText.trim() || fallbackAlt || "Uploaded image";
926
+ formData.set("_payload", JSON.stringify({ alt: resolvedAlt }));
927
+ formData.set("file", file);
928
+ const response = await fetch("/api/media", {
929
+ body: formData,
930
+ credentials: "include",
931
+ method: "POST"
932
+ });
933
+ if (!response.ok) {
934
+ const body = await response.text();
935
+ throw new Error(body || "Upload failed");
936
+ }
937
+ const json = await response.json();
938
+ const uploaded = extractUploadedMedia(json);
939
+ if (!uploaded) {
940
+ throw new Error("Upload succeeded but returned media data was malformed.");
941
+ }
942
+ const nextLayout = cloneBlockLayout(layout);
943
+ const block = nextLayout[selectedIndex];
944
+ if (target === "hero") {
945
+ nextLayout[selectedIndex] = {
946
+ ...block,
947
+ backgroundImageURL: "",
948
+ media: uploaded
949
+ };
950
+ } else {
951
+ nextLayout[selectedIndex] = {
952
+ ...block,
953
+ image: uploaded
954
+ };
955
+ }
956
+ setLayout(nextLayout);
957
+ setUploadMessage("Image uploaded and attached to this section.");
958
+ } catch (error) {
959
+ setUploadError(error instanceof Error ? error.message : "Upload failed.");
960
+ } finally {
961
+ setUploadingTarget(null);
962
+ }
963
+ };
964
+ const toPersistedLayout = (sourceLayout) => sourceLayout.map((block) => {
965
+ if (!block || typeof block !== "object") {
966
+ return block;
967
+ }
968
+ const nextBlock = { ...block };
969
+ const blockType = normalizeText(nextBlock.blockType);
970
+ if (blockType === "hero") {
971
+ const mediaID = getRelationID(nextBlock.media);
972
+ if (mediaID !== null) {
973
+ nextBlock.media = mediaID;
974
+ }
975
+ }
976
+ if (blockType === "media") {
977
+ const imageID = getRelationID(nextBlock.image);
978
+ if (imageID !== null) {
979
+ nextBlock.image = imageID;
980
+ }
981
+ }
982
+ return nextBlock;
983
+ });
876
984
  const sidebarSectionStyle = {
877
985
  border: "1px solid rgba(13, 74, 55, 0.15)",
878
986
  borderRadius: 12,
@@ -921,12 +1029,16 @@ function BuilderPageEditor({ initialDoc, pageID }) {
921
1029
  });
922
1030
  };
923
1031
  const saveLayout = async (status) => {
1032
+ setSavingStatus(status);
1033
+ setSaveError("");
1034
+ setSaveMessage("");
924
1035
  try {
1036
+ const persistedLayout = toPersistedLayout(layout);
925
1037
  const response = await fetch(`/api/pages/${pageID}`, {
926
1038
  body: JSON.stringify({
927
1039
  _status: status,
928
- layout,
929
- studioDocument: layoutToStudioDocument(layout, title),
1040
+ layout: persistedLayout,
1041
+ studioDocument: layoutToStudioDocument(persistedLayout, title),
930
1042
  title
931
1043
  }),
932
1044
  headers: {
@@ -938,6 +1050,7 @@ function BuilderPageEditor({ initialDoc, pageID }) {
938
1050
  const body = await response.text();
939
1051
  throw new Error(body || "Failed to save page");
940
1052
  }
1053
+ setSaveMessage(status === "published" ? "Published." : "Draft saved.");
941
1054
  window.parent?.postMessage(
942
1055
  {
943
1056
  message: status === "published" ? "Published." : "Draft saved.",
@@ -948,8 +1061,10 @@ function BuilderPageEditor({ initialDoc, pageID }) {
948
1061
  },
949
1062
  "*"
950
1063
  );
1064
+ return true;
951
1065
  } catch (error) {
952
1066
  console.error(error);
1067
+ setSaveError("Could not save. Check permissions/session and retry.");
953
1068
  window.parent?.postMessage(
954
1069
  {
955
1070
  message: "Could not save. Check permissions/session and retry.",
@@ -960,6 +1075,9 @@ function BuilderPageEditor({ initialDoc, pageID }) {
960
1075
  },
961
1076
  "*"
962
1077
  );
1078
+ return false;
1079
+ } finally {
1080
+ setSavingStatus(null);
963
1081
  }
964
1082
  };
965
1083
  (0, import_react.useEffect)(() => {
@@ -1632,6 +1750,48 @@ function BuilderPageEditor({ initialDoc, pageID }) {
1632
1750
  }
1633
1751
  ),
1634
1752
  sidebarOpen ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "grid", gap: 12, maxHeight: "calc(100vh - 90px)", overflowY: "auto", padding: 12 }, children: [
1753
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { style: sidebarSectionStyle, children: [
1754
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 13, fontWeight: 700, marginBottom: 8 }, children: "Save & Publish" }),
1755
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", gap: 6 }, children: [
1756
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1757
+ "button",
1758
+ {
1759
+ disabled: savingStatus !== null,
1760
+ onClick: () => void saveLayout("draft"),
1761
+ style: {
1762
+ borderRadius: 999,
1763
+ cursor: savingStatus ? "not-allowed" : "pointer",
1764
+ fontSize: 12,
1765
+ fontWeight: 700,
1766
+ padding: "7px 10px"
1767
+ },
1768
+ type: "button",
1769
+ children: savingStatus === "draft" ? "Saving..." : "Save Draft"
1770
+ }
1771
+ ),
1772
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1773
+ "button",
1774
+ {
1775
+ disabled: savingStatus !== null,
1776
+ onClick: () => void saveLayout("published"),
1777
+ style: {
1778
+ background: "#0f7d52",
1779
+ border: "none",
1780
+ borderRadius: 999,
1781
+ color: "#fff",
1782
+ cursor: savingStatus ? "not-allowed" : "pointer",
1783
+ fontSize: 12,
1784
+ fontWeight: 700,
1785
+ padding: "7px 10px"
1786
+ },
1787
+ type: "button",
1788
+ children: savingStatus === "published" ? "Publishing..." : "Publish"
1789
+ }
1790
+ )
1791
+ ] }),
1792
+ saveMessage ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { color: "#0f7d52", fontSize: 11, fontWeight: 700, marginTop: 8 }, children: saveMessage }) : null,
1793
+ saveError ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { color: "#8d1d1d", fontSize: 11, fontWeight: 700, marginTop: 8 }, children: saveError }) : null
1794
+ ] }),
1635
1795
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { style: sidebarSectionStyle, children: [
1636
1796
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 13, fontWeight: 700, marginBottom: 8 }, children: "Add Sections" }),
1637
1797
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -1788,6 +1948,38 @@ function BuilderPageEditor({ initialDoc, pageID }) {
1788
1948
  value: parseColor(selectedBlock.backgroundColor, "#124a37")
1789
1949
  }
1790
1950
  )
1951
+ ] }),
1952
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: sidebarLabelStyle, children: [
1953
+ "Upload Alt Text",
1954
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1955
+ "input",
1956
+ {
1957
+ onChange: (event) => setUploadAltText(event.target.value),
1958
+ placeholder: "Describe the image",
1959
+ style: sidebarInputStyle,
1960
+ type: "text",
1961
+ value: uploadAltText
1962
+ }
1963
+ )
1964
+ ] }),
1965
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: sidebarLabelStyle, children: [
1966
+ "Upload Hero Background Image",
1967
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1968
+ "input",
1969
+ {
1970
+ accept: "image/*",
1971
+ disabled: uploadingTarget !== null,
1972
+ onChange: (event) => {
1973
+ const file = event.currentTarget.files?.[0];
1974
+ if (file) {
1975
+ void uploadMediaForSelected("hero", file);
1976
+ }
1977
+ event.currentTarget.value = "";
1978
+ },
1979
+ style: sidebarInputStyle,
1980
+ type: "file"
1981
+ }
1982
+ )
1791
1983
  ] })
1792
1984
  ] }) : null,
1793
1985
  selectedType === "featureGrid" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
@@ -1861,20 +2053,54 @@ function BuilderPageEditor({ initialDoc, pageID }) {
1861
2053
  )
1862
2054
  ] })
1863
2055
  ] }) : null,
1864
- selectedType === "media" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: sidebarLabelStyle, children: [
1865
- "Image Size",
1866
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1867
- "select",
1868
- {
1869
- onChange: (event) => updateSelectedField("size", event.target.value),
1870
- style: sidebarInputStyle,
1871
- value: normalizeText(selectedBlock.size, "default"),
1872
- children: [
1873
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "default", children: "Default" }),
1874
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "wide", children: "Wide" })
1875
- ]
1876
- }
1877
- )
2056
+ selectedType === "media" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
2057
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: sidebarLabelStyle, children: [
2058
+ "Image Size",
2059
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
2060
+ "select",
2061
+ {
2062
+ onChange: (event) => updateSelectedField("size", event.target.value),
2063
+ style: sidebarInputStyle,
2064
+ value: normalizeText(selectedBlock.size, "default"),
2065
+ children: [
2066
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "default", children: "Default" }),
2067
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "wide", children: "Wide" })
2068
+ ]
2069
+ }
2070
+ )
2071
+ ] }),
2072
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: sidebarLabelStyle, children: [
2073
+ "Upload Alt Text",
2074
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2075
+ "input",
2076
+ {
2077
+ onChange: (event) => setUploadAltText(event.target.value),
2078
+ placeholder: "Describe the image",
2079
+ style: sidebarInputStyle,
2080
+ type: "text",
2081
+ value: uploadAltText
2082
+ }
2083
+ )
2084
+ ] }),
2085
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: sidebarLabelStyle, children: [
2086
+ "Upload Section Image",
2087
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2088
+ "input",
2089
+ {
2090
+ accept: "image/*",
2091
+ disabled: uploadingTarget !== null,
2092
+ onChange: (event) => {
2093
+ const file = event.currentTarget.files?.[0];
2094
+ if (file) {
2095
+ void uploadMediaForSelected("media", file);
2096
+ }
2097
+ event.currentTarget.value = "";
2098
+ },
2099
+ style: sidebarInputStyle,
2100
+ type: "file"
2101
+ }
2102
+ )
2103
+ ] })
1878
2104
  ] }) : null,
1879
2105
  selectedType === "richText" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: sidebarLabelStyle, children: [
1880
2106
  "Content Width",
@@ -1928,7 +2154,10 @@ function BuilderPageEditor({ initialDoc, pageID }) {
1928
2154
  children: "Add Testimonial"
1929
2155
  }
1930
2156
  ) : null,
1931
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { color: "var(--ink-700)", fontSize: 11 }, children: "Click section text directly on the page for copy edits. Use this panel for layout and style options." })
2157
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { color: "var(--ink-700)", fontSize: 11 }, children: "Click section text directly on the page for copy edits. Use this panel for layout and style options." }),
2158
+ uploadingTarget ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { color: "var(--ink-700)", fontSize: 11 }, children: "Uploading image..." }) : null,
2159
+ uploadMessage ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { color: "#0f7d52", fontSize: 11, fontWeight: 700 }, children: uploadMessage }) : null,
2160
+ uploadError ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { color: "#8d1d1d", fontSize: 11, fontWeight: 700 }, children: uploadError }) : null
1932
2161
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: "var(--ink-700)", fontSize: 12, margin: 0 }, children: "Click a section on the page preview to edit its options here." })
1933
2162
  ] })
1934
2163
  ] }) : null
@@ -490,6 +490,37 @@ function parseColor(value, fallback) {
490
490
  }
491
491
  return fallback;
492
492
  }
493
+ function getRelationID(value) {
494
+ if (typeof value === "number" || typeof value === "string") {
495
+ return value;
496
+ }
497
+ if (!value || typeof value !== "object") {
498
+ return null;
499
+ }
500
+ if ("id" in value) {
501
+ const id = value.id;
502
+ if (typeof id === "number" || typeof id === "string") {
503
+ return id;
504
+ }
505
+ }
506
+ return null;
507
+ }
508
+ function extractUploadedMedia(value) {
509
+ const candidate = value && typeof value === "object" && "doc" in value ? value.doc : value;
510
+ if (!candidate || typeof candidate !== "object") {
511
+ return null;
512
+ }
513
+ const id = getRelationID(candidate);
514
+ if (id === null) {
515
+ return null;
516
+ }
517
+ return {
518
+ alt: typeof candidate.alt === "string" ? candidate.alt : "",
519
+ filename: typeof candidate.filename === "string" ? candidate.filename : "",
520
+ id,
521
+ url: typeof candidate.url === "string" ? candidate.url : ""
522
+ };
523
+ }
493
524
  function InlineText({
494
525
  as = "p",
495
526
  className,
@@ -779,6 +810,13 @@ function BuilderPageEditor({ initialDoc, pageID }) {
779
810
  const [selectedIndex, setSelectedIndex] = useState(null);
780
811
  const [dragIndex, setDragIndex] = useState(null);
781
812
  const [sidebarOpen, setSidebarOpen] = useState(true);
813
+ const [savingStatus, setSavingStatus] = useState(null);
814
+ const [saveMessage, setSaveMessage] = useState("");
815
+ const [saveError, setSaveError] = useState("");
816
+ const [uploadingTarget, setUploadingTarget] = useState(null);
817
+ const [uploadError, setUploadError] = useState("");
818
+ const [uploadMessage, setUploadMessage] = useState("");
819
+ const [uploadAltText, setUploadAltText] = useState("");
782
820
  const selectedBlock = useMemo(
783
821
  () => selectedIndex !== null ? layout[selectedIndex] : null,
784
822
  [layout, selectedIndex]
@@ -845,6 +883,76 @@ function BuilderPageEditor({ initialDoc, pageID }) {
845
883
  const currentItems = Array.isArray(selectedBlock[fieldName]) ? selectedBlock[fieldName] : [];
846
884
  updateSelectedArray(fieldName, [...currentItems, item]);
847
885
  };
886
+ const uploadMediaForSelected = async (target, file) => {
887
+ if (selectedIndex === null) {
888
+ setUploadError("Select a section first.");
889
+ return;
890
+ }
891
+ setUploadingTarget(target);
892
+ setUploadError("");
893
+ setUploadMessage("");
894
+ try {
895
+ const formData = new FormData();
896
+ const fallbackAlt = file.name.replace(/\.[^/.]+$/, "").trim();
897
+ const resolvedAlt = uploadAltText.trim() || fallbackAlt || "Uploaded image";
898
+ formData.set("_payload", JSON.stringify({ alt: resolvedAlt }));
899
+ formData.set("file", file);
900
+ const response = await fetch("/api/media", {
901
+ body: formData,
902
+ credentials: "include",
903
+ method: "POST"
904
+ });
905
+ if (!response.ok) {
906
+ const body = await response.text();
907
+ throw new Error(body || "Upload failed");
908
+ }
909
+ const json = await response.json();
910
+ const uploaded = extractUploadedMedia(json);
911
+ if (!uploaded) {
912
+ throw new Error("Upload succeeded but returned media data was malformed.");
913
+ }
914
+ const nextLayout = cloneBlockLayout(layout);
915
+ const block = nextLayout[selectedIndex];
916
+ if (target === "hero") {
917
+ nextLayout[selectedIndex] = {
918
+ ...block,
919
+ backgroundImageURL: "",
920
+ media: uploaded
921
+ };
922
+ } else {
923
+ nextLayout[selectedIndex] = {
924
+ ...block,
925
+ image: uploaded
926
+ };
927
+ }
928
+ setLayout(nextLayout);
929
+ setUploadMessage("Image uploaded and attached to this section.");
930
+ } catch (error) {
931
+ setUploadError(error instanceof Error ? error.message : "Upload failed.");
932
+ } finally {
933
+ setUploadingTarget(null);
934
+ }
935
+ };
936
+ const toPersistedLayout = (sourceLayout) => sourceLayout.map((block) => {
937
+ if (!block || typeof block !== "object") {
938
+ return block;
939
+ }
940
+ const nextBlock = { ...block };
941
+ const blockType = normalizeText(nextBlock.blockType);
942
+ if (blockType === "hero") {
943
+ const mediaID = getRelationID(nextBlock.media);
944
+ if (mediaID !== null) {
945
+ nextBlock.media = mediaID;
946
+ }
947
+ }
948
+ if (blockType === "media") {
949
+ const imageID = getRelationID(nextBlock.image);
950
+ if (imageID !== null) {
951
+ nextBlock.image = imageID;
952
+ }
953
+ }
954
+ return nextBlock;
955
+ });
848
956
  const sidebarSectionStyle = {
849
957
  border: "1px solid rgba(13, 74, 55, 0.15)",
850
958
  borderRadius: 12,
@@ -893,12 +1001,16 @@ function BuilderPageEditor({ initialDoc, pageID }) {
893
1001
  });
894
1002
  };
895
1003
  const saveLayout = async (status) => {
1004
+ setSavingStatus(status);
1005
+ setSaveError("");
1006
+ setSaveMessage("");
896
1007
  try {
1008
+ const persistedLayout = toPersistedLayout(layout);
897
1009
  const response = await fetch(`/api/pages/${pageID}`, {
898
1010
  body: JSON.stringify({
899
1011
  _status: status,
900
- layout,
901
- studioDocument: layoutToStudioDocument(layout, title),
1012
+ layout: persistedLayout,
1013
+ studioDocument: layoutToStudioDocument(persistedLayout, title),
902
1014
  title
903
1015
  }),
904
1016
  headers: {
@@ -910,6 +1022,7 @@ function BuilderPageEditor({ initialDoc, pageID }) {
910
1022
  const body = await response.text();
911
1023
  throw new Error(body || "Failed to save page");
912
1024
  }
1025
+ setSaveMessage(status === "published" ? "Published." : "Draft saved.");
913
1026
  window.parent?.postMessage(
914
1027
  {
915
1028
  message: status === "published" ? "Published." : "Draft saved.",
@@ -920,8 +1033,10 @@ function BuilderPageEditor({ initialDoc, pageID }) {
920
1033
  },
921
1034
  "*"
922
1035
  );
1036
+ return true;
923
1037
  } catch (error) {
924
1038
  console.error(error);
1039
+ setSaveError("Could not save. Check permissions/session and retry.");
925
1040
  window.parent?.postMessage(
926
1041
  {
927
1042
  message: "Could not save. Check permissions/session and retry.",
@@ -932,6 +1047,9 @@ function BuilderPageEditor({ initialDoc, pageID }) {
932
1047
  },
933
1048
  "*"
934
1049
  );
1050
+ return false;
1051
+ } finally {
1052
+ setSavingStatus(null);
935
1053
  }
936
1054
  };
937
1055
  useEffect(() => {
@@ -1604,6 +1722,48 @@ function BuilderPageEditor({ initialDoc, pageID }) {
1604
1722
  }
1605
1723
  ),
1606
1724
  sidebarOpen ? /* @__PURE__ */ jsxs("div", { style: { display: "grid", gap: 12, maxHeight: "calc(100vh - 90px)", overflowY: "auto", padding: 12 }, children: [
1725
+ /* @__PURE__ */ jsxs("section", { style: sidebarSectionStyle, children: [
1726
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 13, fontWeight: 700, marginBottom: 8 }, children: "Save & Publish" }),
1727
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6 }, children: [
1728
+ /* @__PURE__ */ jsx(
1729
+ "button",
1730
+ {
1731
+ disabled: savingStatus !== null,
1732
+ onClick: () => void saveLayout("draft"),
1733
+ style: {
1734
+ borderRadius: 999,
1735
+ cursor: savingStatus ? "not-allowed" : "pointer",
1736
+ fontSize: 12,
1737
+ fontWeight: 700,
1738
+ padding: "7px 10px"
1739
+ },
1740
+ type: "button",
1741
+ children: savingStatus === "draft" ? "Saving..." : "Save Draft"
1742
+ }
1743
+ ),
1744
+ /* @__PURE__ */ jsx(
1745
+ "button",
1746
+ {
1747
+ disabled: savingStatus !== null,
1748
+ onClick: () => void saveLayout("published"),
1749
+ style: {
1750
+ background: "#0f7d52",
1751
+ border: "none",
1752
+ borderRadius: 999,
1753
+ color: "#fff",
1754
+ cursor: savingStatus ? "not-allowed" : "pointer",
1755
+ fontSize: 12,
1756
+ fontWeight: 700,
1757
+ padding: "7px 10px"
1758
+ },
1759
+ type: "button",
1760
+ children: savingStatus === "published" ? "Publishing..." : "Publish"
1761
+ }
1762
+ )
1763
+ ] }),
1764
+ saveMessage ? /* @__PURE__ */ jsx("div", { style: { color: "#0f7d52", fontSize: 11, fontWeight: 700, marginTop: 8 }, children: saveMessage }) : null,
1765
+ saveError ? /* @__PURE__ */ jsx("div", { style: { color: "#8d1d1d", fontSize: 11, fontWeight: 700, marginTop: 8 }, children: saveError }) : null
1766
+ ] }),
1607
1767
  /* @__PURE__ */ jsxs("section", { style: sidebarSectionStyle, children: [
1608
1768
  /* @__PURE__ */ jsx("div", { style: { fontSize: 13, fontWeight: 700, marginBottom: 8 }, children: "Add Sections" }),
1609
1769
  /* @__PURE__ */ jsxs(
@@ -1760,6 +1920,38 @@ function BuilderPageEditor({ initialDoc, pageID }) {
1760
1920
  value: parseColor(selectedBlock.backgroundColor, "#124a37")
1761
1921
  }
1762
1922
  )
1923
+ ] }),
1924
+ /* @__PURE__ */ jsxs("label", { style: sidebarLabelStyle, children: [
1925
+ "Upload Alt Text",
1926
+ /* @__PURE__ */ jsx(
1927
+ "input",
1928
+ {
1929
+ onChange: (event) => setUploadAltText(event.target.value),
1930
+ placeholder: "Describe the image",
1931
+ style: sidebarInputStyle,
1932
+ type: "text",
1933
+ value: uploadAltText
1934
+ }
1935
+ )
1936
+ ] }),
1937
+ /* @__PURE__ */ jsxs("label", { style: sidebarLabelStyle, children: [
1938
+ "Upload Hero Background Image",
1939
+ /* @__PURE__ */ jsx(
1940
+ "input",
1941
+ {
1942
+ accept: "image/*",
1943
+ disabled: uploadingTarget !== null,
1944
+ onChange: (event) => {
1945
+ const file = event.currentTarget.files?.[0];
1946
+ if (file) {
1947
+ void uploadMediaForSelected("hero", file);
1948
+ }
1949
+ event.currentTarget.value = "";
1950
+ },
1951
+ style: sidebarInputStyle,
1952
+ type: "file"
1953
+ }
1954
+ )
1763
1955
  ] })
1764
1956
  ] }) : null,
1765
1957
  selectedType === "featureGrid" ? /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -1833,20 +2025,54 @@ function BuilderPageEditor({ initialDoc, pageID }) {
1833
2025
  )
1834
2026
  ] })
1835
2027
  ] }) : null,
1836
- selectedType === "media" ? /* @__PURE__ */ jsxs("label", { style: sidebarLabelStyle, children: [
1837
- "Image Size",
1838
- /* @__PURE__ */ jsxs(
1839
- "select",
1840
- {
1841
- onChange: (event) => updateSelectedField("size", event.target.value),
1842
- style: sidebarInputStyle,
1843
- value: normalizeText(selectedBlock.size, "default"),
1844
- children: [
1845
- /* @__PURE__ */ jsx("option", { value: "default", children: "Default" }),
1846
- /* @__PURE__ */ jsx("option", { value: "wide", children: "Wide" })
1847
- ]
1848
- }
1849
- )
2028
+ selectedType === "media" ? /* @__PURE__ */ jsxs(Fragment, { children: [
2029
+ /* @__PURE__ */ jsxs("label", { style: sidebarLabelStyle, children: [
2030
+ "Image Size",
2031
+ /* @__PURE__ */ jsxs(
2032
+ "select",
2033
+ {
2034
+ onChange: (event) => updateSelectedField("size", event.target.value),
2035
+ style: sidebarInputStyle,
2036
+ value: normalizeText(selectedBlock.size, "default"),
2037
+ children: [
2038
+ /* @__PURE__ */ jsx("option", { value: "default", children: "Default" }),
2039
+ /* @__PURE__ */ jsx("option", { value: "wide", children: "Wide" })
2040
+ ]
2041
+ }
2042
+ )
2043
+ ] }),
2044
+ /* @__PURE__ */ jsxs("label", { style: sidebarLabelStyle, children: [
2045
+ "Upload Alt Text",
2046
+ /* @__PURE__ */ jsx(
2047
+ "input",
2048
+ {
2049
+ onChange: (event) => setUploadAltText(event.target.value),
2050
+ placeholder: "Describe the image",
2051
+ style: sidebarInputStyle,
2052
+ type: "text",
2053
+ value: uploadAltText
2054
+ }
2055
+ )
2056
+ ] }),
2057
+ /* @__PURE__ */ jsxs("label", { style: sidebarLabelStyle, children: [
2058
+ "Upload Section Image",
2059
+ /* @__PURE__ */ jsx(
2060
+ "input",
2061
+ {
2062
+ accept: "image/*",
2063
+ disabled: uploadingTarget !== null,
2064
+ onChange: (event) => {
2065
+ const file = event.currentTarget.files?.[0];
2066
+ if (file) {
2067
+ void uploadMediaForSelected("media", file);
2068
+ }
2069
+ event.currentTarget.value = "";
2070
+ },
2071
+ style: sidebarInputStyle,
2072
+ type: "file"
2073
+ }
2074
+ )
2075
+ ] })
1850
2076
  ] }) : null,
1851
2077
  selectedType === "richText" ? /* @__PURE__ */ jsxs("label", { style: sidebarLabelStyle, children: [
1852
2078
  "Content Width",
@@ -1900,7 +2126,10 @@ function BuilderPageEditor({ initialDoc, pageID }) {
1900
2126
  children: "Add Testimonial"
1901
2127
  }
1902
2128
  ) : null,
1903
- /* @__PURE__ */ jsx("div", { style: { color: "var(--ink-700)", fontSize: 11 }, children: "Click section text directly on the page for copy edits. Use this panel for layout and style options." })
2129
+ /* @__PURE__ */ jsx("div", { style: { color: "var(--ink-700)", fontSize: 11 }, children: "Click section text directly on the page for copy edits. Use this panel for layout and style options." }),
2130
+ uploadingTarget ? /* @__PURE__ */ jsx("div", { style: { color: "var(--ink-700)", fontSize: 11 }, children: "Uploading image..." }) : null,
2131
+ uploadMessage ? /* @__PURE__ */ jsx("div", { style: { color: "#0f7d52", fontSize: 11, fontWeight: 700 }, children: uploadMessage }) : null,
2132
+ uploadError ? /* @__PURE__ */ jsx("div", { style: { color: "#8d1d1d", fontSize: 11, fontWeight: 700 }, children: uploadError }) : null
1904
2133
  ] }) : /* @__PURE__ */ jsx("p", { style: { color: "var(--ink-700)", fontSize: 12, margin: 0 }, children: "Click a section on the page preview to edit its options here." })
1905
2134
  ] })
1906
2135
  ] }) : null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/payload-studio",
3
- "version": "0.4.0-beta.0",
3
+ "version": "0.4.0-beta.2",
4
4
  "description": "Unified Payload CMS toolkit for Orion Studios",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {