@goodfellow-cms/admin 0.7.0 → 0.9.0

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/dist/editor.js CHANGED
@@ -1,11 +1,12 @@
1
- import { Bt as editedMarkdown, It as storedData, Lt as storedEntryFields, R as createMediaPreviews, Vt as formattedHtml, a as AdminContext, at as useStrings, b as MediaChooser, f as useDarkMode, h as ScheduleFields, i as usePreviewStyles, l as useSiteContent, nt as StringsProvider, q as READY_MESSAGE, s as useAdmin, st as findPage, t as SCHEDULE_PROP, u as setThemeChoice, ut as mediaUrl, w as SETTINGS_SECTIONS, y as MediaChoiceContext, z as showMediaInPreview } from "./entry-values-DO01xZ9L.js";
2
- import { r as createChannel } from "./plugin-protocol-DkvBDqsF.js";
3
- import { COLOR_SCHEME_ATTRIBUTE, DEFAULT_THEME_COLORS, DRAWN_SLOT_ELEMENT, FONT_SIZES, MEDIA_DIR, MINIMUM_CONTRAST, RADII, SIDES, SPACING_STEPS, TEXT_ALIGNS, THEME_COLORS, WEEKDAYS, allPages, canFormatMarkdown, cleanEventValue, colorSlug, contentInLanguage, contrastRatio, customLength, datePart, escapeStyleText, findEntry, formatDate, formatOccurrence, getPageHead, googleFontsUrl, hasLanguages, isAllDay, isCustomColor, isEventLike, languageHome, languageLinks, mainLanguage, neverIndexed, pageLinks, pageView, pathLanguage, readBlockStyle, renderedProblems, shownOccurrence, sitePages, themeColorOf, themeToCss, todayIn, wallClock, weekdayName, weekdayOf, withBlockStyle } from "@goodfellow-cms/core";
4
- import { createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
1
+ import { $ as formattedHtml, K as storedData, Q as editedMarkdown, _ as mediaUrl, a as showMediaInPreview, q as storedEntryFields, r as createMediaPreviews, s as createChannel, t as installCustomFonts } from "./custom-font-faces-DJ2AF2oN.js";
2
+ import { C as MediaChoiceContext, D as MediaThumb, G as isForwardedShortcut, H as READY_MESSAGE, J as shortcutFor, K as isTyping, X as SETTINGS_SECTIONS, c as AdminContext, f as useSiteContent, g as useDarkMode, i as usePreviewStyles, lt as Button, m as setThemeChoice, ot as EditorTextContext, rt as syncedBlockEditorHref, s as withBlocksInPlace, st as useEditorText, t as SCHEDULE_PROP, u as useAdmin, w as MediaChooser, y as ScheduleFields, yt as findPage } from "./entry-values-FcVgwQkJ.js";
3
+ import { i as useStrings, t as StringsProvider } from "./strings-D94noZrv.js";
4
+ import { COLOR_SCHEME_ATTRIBUTE, DEFAULT_THEME_COLORS, DRAWN_SLOT_ELEMENT, FONT_SIZES, MEDIA_DIR, MINIMUM_CONTRAST, RADII, SCREEN_SIZES, SIDES, SPACING_STEPS, SYNCED_BLOCK, SYNCED_BLOCK_PROP, TEXT_ALIGNS, THEME_COLORS, WEEKDAYS, allPages, canFormatMarkdown, cleanEventValue, colorSlug, contentInLanguage, contrastRatio, customFonts, customLength, datePart, describedIn, entryTitle, escapeCellText, escapeStyleText, expandSyncedBlocks, findEntry, findSyncedBlock, focusPosition, formatDate, formatOccurrence, getPageHead, googleFontsUrl, hasLanguages, isAllDay, isCustomColor, isEventLike, isOwnersOnlyField, isSafeUrl, languageHome, languageLinks, mainLanguage, neverIndexed, pageLinks, pageView, pathLanguage, readBlockStyle, renderedProblems, shownOccurrence, sitePages, syncedBlockIn, tableCellHtml, themeColorOf, themeToCss, todayIn, wallClock, weekdayName, weekdayOf, withBlockStyle } from "@goodfellow-cms/core";
5
+ import { Box } from "lucide-react";
6
+ import { createContext, createElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
5
7
  import { createRoot } from "react-dom/client";
6
8
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
- import { PageBody, PageContent, SiteProvider, adminSite, applyEntry, blockIcon, blockKeywords, createPuckConfig, createPuckConfigs, cx, isStyleField, isTableField, mediaFieldKind, preparePage, siteMetadata, tableCells } from "@goodfellow-cms/react";
8
- import { Box } from "lucide-react";
9
+ import { PageBody, PageContent, SiteProvider, adminSite, applyEntry, blockIcon, blockKeywords, createPuckConfig, createPuckConfigs, cx, isStyleField, isTableField, mediaFieldKind, pageHeadings, preparePage, siteMetadata, tableCells } from "@goodfellow-cms/react";
9
10
  import { AutoField, Drawer, FieldLabel, Puck, Render, RichTextMenu, createUsePuck, legacySideBarPlugin, migrate, registerOverlayPortal, resolveAllData, useGetPuck } from "@puckeditor/core";
10
11
  import { createPortal, flushSync } from "react-dom";
11
12
  const CANVAS = `.gf-canvas`;
@@ -190,14 +191,16 @@ function installSiteCss(css) {
190
191
  }
191
192
  /**
192
193
  * The theme, fonts, custom CSS and Tailwind for the canvas, as the site
193
- * shows them, light or dark as `scheme` says.
194
+ * shows them, light or dark as `scheme` says. The site's own fonts come from
195
+ * their bytes, through `media`, since the frame can't load the site's files.
194
196
  */
195
- function useCanvasStyles(preview, styles) {
197
+ function useCanvasStyles(preview, styles, media) {
196
198
  const { theme, customCss, scheme } = styles;
197
199
  useEffect(() => scopeTailwindOutput(), []);
198
200
  useEffect(() => {
199
201
  document.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, theme.darkMode ? scheme : "light");
200
- styleElement("theme").textContent = escapeStyleText(scopeCss(themeToCss(theme)));
202
+ styleElement("theme").textContent = escapeStyleText(scopeCss(themeToCss(theme, void 0, { fontFaces: false })));
203
+ installCustomFonts(document, customFonts(theme), (url) => media.bytes(url));
201
204
  const fonts = googleFontsUrl(theme);
202
205
  const link = document.head.querySelector("link[data-gf=\"fonts\"]");
203
206
  if (fonts) {
@@ -207,7 +210,11 @@ function useCanvasStyles(preview, styles) {
207
210
  element.href = fonts;
208
211
  if (!link) document.head.append(element);
209
212
  } else link?.remove();
210
- }, [theme, scheme]);
213
+ }, [
214
+ theme,
215
+ scheme,
216
+ media
217
+ ]);
211
218
  useEffect(() => {
212
219
  const text = escapeStyleText(`${preview.themeCss}\n${customCss}`);
213
220
  const current = document.head.querySelector("style[data-gf=\"tailwind\"]");
@@ -234,7 +241,8 @@ const usePuck$2 = createUsePuck();
234
241
  /**
235
242
  * How the page looks when a link to it is shared, such as in a message or on
236
243
  * social media: its title, description and image as link previews show them,
237
- * from the page's settings or else Site Settings', updated as they're edited.
244
+ * from the page's settings or else Site Settings', updated as they're edited,
245
+ * with the picture large over the title or small beside it, as its settings say.
238
246
  */
239
247
  function SharePreview({ path }) {
240
248
  const t = useStrings();
@@ -265,7 +273,7 @@ function SharePreview({ path }) {
265
273
  children: t("sharePreview.title")
266
274
  }),
267
275
  /* @__PURE__ */ jsxs("div", {
268
- className: "gfa-share-card",
276
+ className: head.card === "summary" ? "gfa-share-card gfa-share-card-small" : "gfa-share-card",
269
277
  children: [image ? /* @__PURE__ */ jsx("img", {
270
278
  src: image,
271
279
  alt: "",
@@ -709,6 +717,135 @@ function EventField({ id, label, hint, value, onChange, language, timeZone }) {
709
717
  });
710
718
  }
711
719
  //#endregion
720
+ //#region src/item-fields.tsx
721
+ /**
722
+ * The item editor's controls for links to items and for several images: a
723
+ * searchable list of another collection's items, and a list of images to add
724
+ * to, order and take out.
725
+ */
726
+ /** How many matches the item picker lists at once; searching narrows the rest. */
727
+ const SHOWN_MATCHES = 50;
728
+ /** The items a link can name: the collection's own, never translations, which links reach through the item they translate. */
729
+ function linkableItems(collection) {
730
+ return (collection?.entries ?? []).filter((entry) => entry.content.translationOf === void 0).map((entry) => ({
731
+ slug: entry.slug,
732
+ title: entryTitle(entry)
733
+ })).sort((a, b) => a.title.localeCompare(b.title));
734
+ }
735
+ /** Chooses one item, or several, of a collection, by searching their titles. */
736
+ function ItemPicker({ id, collection, multiple, value, onChange }) {
737
+ const t = useStrings();
738
+ const listId = useId();
739
+ const [search, setSearch] = useState("");
740
+ const items = linkableItems(collection);
741
+ const chosen = typeof value === "string" ? value ? [value] : [] : Array.isArray(value) ? value.map(String) : [];
742
+ const name = collection?.settings.name ?? "";
743
+ const titleOf = (slug) => items.find((item) => item.slug === slug)?.title ?? slug;
744
+ const words = search.trim().toLowerCase();
745
+ const matches = items.filter((item) => !chosen.includes(item.slug) && (!words || item.title.toLowerCase().includes(words)));
746
+ const set = (next) => onChange(multiple ? next.length > 0 ? next : void 0 : next[0]);
747
+ if (!collection) return /* @__PURE__ */ jsx("p", {
748
+ className: "gfa-puck-hint",
749
+ children: t("editEntry.items.missing")
750
+ });
751
+ if (items.length === 0) return /* @__PURE__ */ jsx("p", {
752
+ className: "gfa-puck-hint",
753
+ children: t("editEntry.items.empty", { name })
754
+ });
755
+ return /* @__PURE__ */ jsxs("div", {
756
+ className: "gfa-item-picker",
757
+ children: [chosen.length > 0 && /* @__PURE__ */ jsx("ul", {
758
+ className: "gfa-item-picker-chosen",
759
+ children: chosen.map((slug) => /* @__PURE__ */ jsxs("li", {
760
+ className: "gfa-chosen-row",
761
+ children: [/* @__PURE__ */ jsx("span", {
762
+ className: "gfa-chosen-row-name",
763
+ children: titleOf(slug)
764
+ }), /* @__PURE__ */ jsx(Button, {
765
+ variant: "ghost",
766
+ "aria-label": t("editEntry.items.remove", { title: titleOf(slug) }),
767
+ onClick: () => set(chosen.filter((other) => other !== slug)),
768
+ children: "×"
769
+ })]
770
+ }, slug))
771
+ }), (multiple || chosen.length === 0) && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("input", {
772
+ id,
773
+ type: "search",
774
+ className: "gfa-puck-input",
775
+ placeholder: t("editEntry.items.search", { name }),
776
+ "aria-controls": listId,
777
+ value: search,
778
+ onChange: (event) => setSearch(event.target.value)
779
+ }), /* @__PURE__ */ jsxs("ul", {
780
+ id: listId,
781
+ className: "gfa-item-picker-matches",
782
+ children: [matches.slice(0, SHOWN_MATCHES).map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
783
+ type: "button",
784
+ onClick: () => {
785
+ set([...chosen, item.slug]);
786
+ setSearch("");
787
+ },
788
+ children: item.title
789
+ }) }, item.slug)), matches.length === 0 && /* @__PURE__ */ jsx("li", {
790
+ className: "gfa-puck-hint",
791
+ children: t("editEntry.items.none", { name })
792
+ })]
793
+ })] })]
794
+ });
795
+ }
796
+ /** Several images, in order: each can move up or down or be taken out, and the media chooser adds more. */
797
+ function ImagesField({ value, onChange }) {
798
+ const t = useStrings();
799
+ const images = Array.isArray(value) ? value.filter((item) => typeof item === "string" && !!item) : [];
800
+ const set = (next) => onChange(next.length > 0 ? next : void 0);
801
+ const move = (index, by) => {
802
+ const next = [...images];
803
+ const [moved] = next.splice(index, 1);
804
+ next.splice(index + by, 0, moved);
805
+ set(next);
806
+ };
807
+ return /* @__PURE__ */ jsxs("div", {
808
+ className: "gfa-images-field",
809
+ children: [images.length > 0 && /* @__PURE__ */ jsx("ul", { children: images.map((src, index) => /* @__PURE__ */ jsxs("li", {
810
+ className: "gfa-chosen-row",
811
+ children: [
812
+ /* @__PURE__ */ jsx(MediaThumb, {
813
+ url: src,
814
+ className: "gfa-images-field-thumb"
815
+ }),
816
+ /* @__PURE__ */ jsx("span", {
817
+ className: "gfa-chosen-row-name",
818
+ children: src.slice(src.lastIndexOf("/") + 1)
819
+ }),
820
+ /* @__PURE__ */ jsx(Button, {
821
+ variant: "ghost",
822
+ disabled: index === 0,
823
+ "aria-label": t("editEntry.images.up"),
824
+ onClick: () => move(index, -1),
825
+ children: "↑"
826
+ }),
827
+ /* @__PURE__ */ jsx(Button, {
828
+ variant: "ghost",
829
+ disabled: index === images.length - 1,
830
+ "aria-label": t("editEntry.images.down"),
831
+ onClick: () => move(index, 1),
832
+ children: "↓"
833
+ }),
834
+ /* @__PURE__ */ jsx(Button, {
835
+ variant: "ghost",
836
+ "aria-label": t("editEntry.images.remove"),
837
+ onClick: () => set(images.filter((_, other) => other !== index)),
838
+ children: "×"
839
+ })
840
+ ]
841
+ }, src)) }), /* @__PURE__ */ jsx(MediaChooser, {
842
+ value: "",
843
+ kind: "image",
844
+ onChange: (src) => src && !images.includes(src) && set([...images, src])
845
+ })]
846
+ });
847
+ }
848
+ //#endregion
712
849
  //#region src/markdown-field.tsx
713
850
  /**
714
851
  * Puck's formatted editor, without what Markdown can't store: underlining and
@@ -839,7 +976,7 @@ function Hint({ text }) {
839
976
  }) : null;
840
977
  }
841
978
  /** What the AI assistant is told a field holds, since it can't tell from a custom field. */
842
- function aiHint(field, markdown) {
979
+ function aiHint(field, markdown, collections) {
843
980
  const description = field.hint;
844
981
  if (markdown) return {
845
982
  type: "string",
@@ -864,6 +1001,25 @@ function aiHint(field, markdown) {
864
1001
  options: field.options ?? [],
865
1002
  description
866
1003
  };
1004
+ case "yesno": return {
1005
+ type: "boolean",
1006
+ description
1007
+ };
1008
+ case "item": {
1009
+ const options = linkableItems(collections.find((candidate) => candidate.id === field.collection)).slice(0, MAX_AI_ITEMS).map((item) => ({
1010
+ value: item.slug,
1011
+ label: item.title
1012
+ }));
1013
+ if (options.length === 0) return void 0;
1014
+ return {
1015
+ type: field.multiple ? "choices" : "choice",
1016
+ options,
1017
+ description
1018
+ };
1019
+ }
1020
+ case "images":
1021
+ case "file":
1022
+ case "group": return;
867
1023
  case "link": return {
868
1024
  type: "string",
869
1025
  description: [description, "A web address or a page address such as /about"].filter(Boolean).join(". ")
@@ -878,16 +1034,102 @@ function aiHint(field, markdown) {
878
1034
  };
879
1035
  }
880
1036
  }
1037
+ /** The most items the assistant is offered to link to, so a large collection doesn't fill its request. */
1038
+ const MAX_AI_ITEMS = 200;
881
1039
  /** The Puck field for one of a collection's fields, shown in the editor's sidebar. */
882
- function puckField(field, collection, settings) {
1040
+ function puckField(field, collection, content) {
883
1041
  const markdown = collection.settings.markdown?.body === field.name;
884
- const control = fieldControl(field, markdown, settings);
885
- return field.type === "event" ? control : {
1042
+ const control = fieldControl(field, markdown, content);
1043
+ const ai = field.type === "event" ? void 0 : aiHint(field, markdown, content.collections);
1044
+ return ai ? {
886
1045
  ...control,
887
- metadata: { ai: aiHint(field, markdown) }
888
- };
1046
+ metadata: { ai }
1047
+ } : control;
1048
+ }
1049
+ /** A text field for an address, with the media chooser below it. */
1050
+ function addressControl(label, hint, kind) {
1051
+ return { render: ({ value, onChange, id }) => /* @__PURE__ */ jsxs(FieldLabel, {
1052
+ label,
1053
+ children: [
1054
+ /* @__PURE__ */ jsx(AutoField, {
1055
+ id,
1056
+ field: { type: "text" },
1057
+ value: typeof value === "string" ? value : "",
1058
+ onChange
1059
+ }),
1060
+ /* @__PURE__ */ jsx(MediaChooser, {
1061
+ value: typeof value === "string" ? value : "",
1062
+ kind,
1063
+ onChange
1064
+ }),
1065
+ /* @__PURE__ */ jsx(Hint, { text: hint })
1066
+ ]
1067
+ }) };
889
1068
  }
890
- function fieldControl(field, markdown, settings) {
1069
+ /** A checkbox for a yes or no field, which stores nothing when it's off. */
1070
+ function YesNo({ id, label, hint, value, onChange }) {
1071
+ return /* @__PURE__ */ jsxs("div", {
1072
+ className: "gfa-puck-field",
1073
+ children: [/* @__PURE__ */ jsxs("label", {
1074
+ className: "gfa-checkbox",
1075
+ children: [/* @__PURE__ */ jsx("input", {
1076
+ id,
1077
+ type: "checkbox",
1078
+ checked: value === true,
1079
+ onChange: (event) => onChange(event.target.checked ? true : void 0)
1080
+ }), label]
1081
+ }), /* @__PURE__ */ jsx(Hint, { text: hint })]
1082
+ });
1083
+ }
1084
+ /** One of a repeating group's fields, as the group's rows show it. */
1085
+ function groupFieldControl(field) {
1086
+ switch (field.type) {
1087
+ case "textarea": return {
1088
+ type: "textarea",
1089
+ label: field.label
1090
+ };
1091
+ case "number": return {
1092
+ type: "number",
1093
+ label: field.label
1094
+ };
1095
+ case "date": return {
1096
+ type: "custom",
1097
+ label: field.label,
1098
+ render: ({ value, onChange, id }) => /* @__PURE__ */ jsx(FieldLabel, {
1099
+ label: field.label,
1100
+ children: /* @__PURE__ */ jsx("input", {
1101
+ id,
1102
+ type: "date",
1103
+ className: "gfa-puck-input",
1104
+ value: typeof value === "string" ? value : "",
1105
+ onChange: (event) => onChange(event.target.value)
1106
+ })
1107
+ })
1108
+ };
1109
+ case "yesno": return {
1110
+ type: "custom",
1111
+ label: field.label,
1112
+ render: ({ value, onChange, id }) => /* @__PURE__ */ jsx(YesNo, {
1113
+ id,
1114
+ label: field.label,
1115
+ value,
1116
+ onChange
1117
+ })
1118
+ };
1119
+ case "image":
1120
+ case "link": return {
1121
+ type: "custom",
1122
+ label: field.label,
1123
+ ...addressControl(field.label, void 0, field.type === "image" ? "image" : "file")
1124
+ };
1125
+ default: return {
1126
+ type: "text",
1127
+ label: field.label
1128
+ };
1129
+ }
1130
+ }
1131
+ function fieldControl(field, markdown, content) {
1132
+ const { settings } = content;
891
1133
  const label = field.required ? `${field.label} *` : field.label;
892
1134
  if (markdown) return {
893
1135
  type: "custom",
@@ -965,6 +1207,59 @@ function fieldControl(field, markdown, settings) {
965
1207
  });
966
1208
  }
967
1209
  };
1210
+ case "yesno": return {
1211
+ type: "custom",
1212
+ label,
1213
+ render: ({ value, onChange, id }) => /* @__PURE__ */ jsx(YesNo, {
1214
+ id,
1215
+ label,
1216
+ hint: field.hint,
1217
+ value,
1218
+ onChange
1219
+ })
1220
+ };
1221
+ case "item": return {
1222
+ type: "custom",
1223
+ label,
1224
+ render: ({ value, onChange, id }) => /* @__PURE__ */ jsxs(FieldLabel, {
1225
+ label,
1226
+ el: "div",
1227
+ children: [/* @__PURE__ */ jsx(ItemPicker, {
1228
+ id,
1229
+ collection: content.collections.find((candidate) => candidate.id === field.collection),
1230
+ multiple: field.multiple === true,
1231
+ value,
1232
+ onChange
1233
+ }), /* @__PURE__ */ jsx(Hint, { text: field.hint })]
1234
+ })
1235
+ };
1236
+ case "images": return {
1237
+ type: "custom",
1238
+ label,
1239
+ render: ({ value, onChange }) => /* @__PURE__ */ jsxs(FieldLabel, {
1240
+ label,
1241
+ el: "div",
1242
+ children: [/* @__PURE__ */ jsx(ImagesField, {
1243
+ value,
1244
+ onChange
1245
+ }), /* @__PURE__ */ jsx(Hint, { text: field.hint })]
1246
+ })
1247
+ };
1248
+ case "file": return {
1249
+ type: "custom",
1250
+ label,
1251
+ ...addressControl(label, field.hint, "file")
1252
+ };
1253
+ case "group": {
1254
+ const subFields = field.fields ?? [];
1255
+ const first = subFields[0]?.name;
1256
+ return {
1257
+ type: "array",
1258
+ label,
1259
+ arrayFields: Object.fromEntries(subFields.map((sub) => [sub.name, groupFieldControl(sub)])),
1260
+ getItemSummary: (row, index) => first && typeof row?.[first] === "string" && row[first] || `${(index ?? 0) + 1}`
1261
+ };
1262
+ }
968
1263
  case "event": return {
969
1264
  type: "custom",
970
1265
  label,
@@ -1041,12 +1336,11 @@ function EntryPreview({ collection, entry, content, templateConfig, layoutConfig
1041
1336
  current
1042
1337
  ]);
1043
1338
  const metadata = useMemo(() => siteMetadata(site), [site]);
1044
- const applied = useMemo(() => applyEntry(migrate(collection.settings.template), templateConfig, collection, current, content.settings.language, content.settings.timeZone), [
1339
+ const applied = useMemo(() => applyEntry(expandSyncedBlocks(migrate(collection.settings.template), content, content.settings.language), templateConfig, collection, current, content.settings.language, content.settings.timeZone, content.collections), [
1045
1340
  collection,
1046
1341
  templateConfig,
1047
1342
  current,
1048
- content.settings.language,
1049
- content.settings.timeZone
1343
+ content
1050
1344
  ]);
1051
1345
  const [resolved, setResolved] = useState(applied);
1052
1346
  useEffect(() => {
@@ -1076,8 +1370,8 @@ function EntryPreview({ collection, entry, content, templateConfig, layoutConfig
1076
1370
  value: site,
1077
1371
  children: /* @__PURE__ */ jsx(SiteFrame, {
1078
1372
  layoutConfig,
1079
- header: content.header.data,
1080
- footer: content.footer.data,
1373
+ header: expandSyncedBlocks(content.header.data, content, content.settings.language),
1374
+ footer: expandSyncedBlocks(content.footer.data, content, content.settings.language),
1081
1375
  site,
1082
1376
  className: typeof className === "string" ? className : void 0,
1083
1377
  children: /* @__PURE__ */ jsx(Render, {
@@ -1104,7 +1398,7 @@ function entryConfig(collection, entry, content, configs) {
1104
1398
  return {
1105
1399
  components: {},
1106
1400
  root: {
1107
- fields: Object.fromEntries([...collection.settings.fields.map((field) => [field.name, puckField(field, collection, content.settings)]), [SCHEDULE_PROP, scheduleField(content)]]),
1401
+ fields: Object.fromEntries([...collection.settings.fields.map((field) => [field.name, puckField(field, collection, content)]), [SCHEDULE_PROP, scheduleField(content)]]),
1108
1402
  render: () => /* @__PURE__ */ jsx("div", {
1109
1403
  className: "gf-canvas",
1110
1404
  children: /* @__PURE__ */ jsx(EntryPreview, {
@@ -1149,25 +1443,56 @@ function withSlotPlaces(block, config) {
1149
1443
  }
1150
1444
  };
1151
1445
  }
1152
- /** One block drawn as the site draws it, in the page at its address, as `drawBlock()` describes. */
1153
- async function drawBlock(configs, content, { kind, path, block }) {
1446
+ /** The config a target's block is in, and the page it's on, with that block alone. */
1447
+ function targetPage(configs, content, { kind, path, block }) {
1154
1448
  const layout = kind === "header" || kind === "footer";
1155
1449
  const config = layout ? configs.layout : kind === "template" ? configs.template : configs.page;
1156
1450
  if (!config.components[block.type]) throw new Error(`The site has no block called ${block.type} here.`);
1157
1451
  const found = allPages(content).find((page) => page.path === path);
1158
1452
  const root = layout ? (kind === "header" ? content.header : content.footer).data.root : found?.content.data.root;
1159
- const page = {
1160
- path,
1161
- file: found?.file ?? "",
1162
- content: {
1163
- ...found?.content,
1164
- version: found?.content.version ?? 1,
1165
- data: {
1166
- root: root ?? { props: {} },
1167
- content: [withSlotPlaces(block, config.components[block.type])]
1453
+ return {
1454
+ config,
1455
+ page: {
1456
+ path,
1457
+ file: found?.file ?? "",
1458
+ content: {
1459
+ ...found?.content,
1460
+ version: found?.content.version ?? 1,
1461
+ data: {
1462
+ root: root ?? { props: {} },
1463
+ content: [withSlotPlaces(block, config.components[block.type])]
1464
+ }
1168
1465
  }
1169
1466
  }
1170
1467
  };
1468
+ }
1469
+ /** A copy of fields as JSON, as the site's description has them: what they say, without the code in them. */
1470
+ function fieldsAsJson(fields) {
1471
+ return JSON.parse(JSON.stringify(fields ?? {}));
1472
+ }
1473
+ /** A block's fields as its props make them, as `blockFields()` describes. */
1474
+ async function blockFields(configs, content, target) {
1475
+ const { config, page } = targetPage(configs, content, target);
1476
+ const component = config.components[target.block.type];
1477
+ const fields = component.fields ?? {};
1478
+ if (!component.resolveFields) return fieldsAsJson(fields);
1479
+ const prepared = await preparePage({
1480
+ ...configs,
1481
+ page: config
1482
+ }, content, page);
1483
+ return fieldsAsJson(await component.resolveFields(target.block, {
1484
+ changed: {},
1485
+ fields,
1486
+ lastFields: fields,
1487
+ lastData: null,
1488
+ metadata: siteMetadata(prepared.site),
1489
+ appState: {},
1490
+ parent: null
1491
+ }));
1492
+ }
1493
+ /** One block drawn as the site draws it, in the page at its address, as `drawBlock()` describes. */
1494
+ async function drawBlock(configs, content, target) {
1495
+ const { config, page } = targetPage(configs, content, target);
1171
1496
  const prepared = await preparePage({
1172
1497
  ...configs,
1173
1498
  page: config
@@ -1242,6 +1567,9 @@ function localSiteCode(config) {
1242
1567
  }
1243
1568
  });
1244
1569
  return drawBlock(drawing, content, target);
1570
+ },
1571
+ async blockFields(content, target) {
1572
+ return blockFields(configs, content, target);
1245
1573
  }
1246
1574
  };
1247
1575
  }
@@ -1289,6 +1617,7 @@ function FrameProviders({ config, client, init, site, children }) {
1289
1617
  pageConfig: createPuckConfig(config, "page"),
1290
1618
  layoutConfig: createPuckConfig(config, "layout"),
1291
1619
  templateConfig: createPuckConfig(config, "template"),
1620
+ syncedConfig: createPuckConfig(config, "synced"),
1292
1621
  code: localSiteCode(config)
1293
1622
  }), [config]);
1294
1623
  const described = useMemo(() => adminSite(config), [config]);
@@ -1354,17 +1683,24 @@ function FrameProviders({ config, client, init, site, children }) {
1354
1683
  kind,
1355
1684
  value
1356
1685
  }), [client]);
1686
+ const text = useMemo(() => ({
1687
+ puck: init.puck ?? {},
1688
+ labels: init.labels ?? {}
1689
+ }), [init.puck, init.labels]);
1357
1690
  return /* @__PURE__ */ jsx(StringsProvider, {
1358
1691
  strings: init.strings,
1359
- children: /* @__PURE__ */ jsx(AdminClientContext.Provider, {
1360
- value: client,
1361
- children: /* @__PURE__ */ jsx(FrameCodeContext.Provider, {
1362
- value: code,
1363
- children: /* @__PURE__ */ jsx(AdminContext.Provider, {
1364
- value: admin,
1365
- children: /* @__PURE__ */ jsx(MediaChoiceContext.Provider, {
1366
- value: chooseMedia,
1367
- children
1692
+ children: /* @__PURE__ */ jsx(EditorTextContext.Provider, {
1693
+ value: text,
1694
+ children: /* @__PURE__ */ jsx(AdminClientContext.Provider, {
1695
+ value: client,
1696
+ children: /* @__PURE__ */ jsx(FrameCodeContext.Provider, {
1697
+ value: code,
1698
+ children: /* @__PURE__ */ jsx(AdminContext.Provider, {
1699
+ value: admin,
1700
+ children: /* @__PURE__ */ jsx(MediaChoiceContext.Provider, {
1701
+ value: chooseMedia,
1702
+ children
1703
+ })
1368
1704
  })
1369
1705
  })
1370
1706
  })
@@ -1372,21 +1708,149 @@ function FrameProviders({ config, client, init, site, children }) {
1372
1708
  });
1373
1709
  }
1374
1710
  //#endregion
1711
+ //#region src/frame/labels.ts
1712
+ /** A label in the admin panel's language, or as it is where the table has none. */
1713
+ function translated(labels, text) {
1714
+ return text === void 0 ? text : labels[text] ?? text;
1715
+ }
1716
+ function withFieldLabels(field, labels) {
1717
+ return {
1718
+ ...field,
1719
+ ...field.label !== void 0 && { label: translated(labels, field.label) },
1720
+ ...typeof field.placeholder === "string" && { placeholder: translated(labels, field.placeholder) },
1721
+ ...Array.isArray(field.options) && { options: field.options.map((option) => ({
1722
+ ...option,
1723
+ label: translated(labels, option.label) ?? option.label
1724
+ })) },
1725
+ ...field.arrayFields && { arrayFields: withFieldsLabels(field.arrayFields, labels) },
1726
+ ...field.objectFields && { objectFields: withFieldsLabels(field.objectFields, labels) }
1727
+ };
1728
+ }
1729
+ function withFieldsLabels(fields, labels) {
1730
+ return Object.fromEntries(Object.entries(fields).map(([name, field]) => [name, withFieldLabels(field, labels)]));
1731
+ }
1732
+ function withComponentLabels(component, labels) {
1733
+ if (!component) return component;
1734
+ const { resolveFields, label } = component;
1735
+ return {
1736
+ ...component,
1737
+ ...label !== void 0 && { label: translated(labels, label) },
1738
+ ...component.fields && { fields: withFieldsLabels(component.fields, labels) },
1739
+ ...resolveFields && { resolveFields: async (...args) => withFieldsLabels(await resolveFields(...args), labels) }
1740
+ };
1741
+ }
1742
+ /**
1743
+ * The config with blocks' names, groups and settings' labels, choices and
1744
+ * placeholders in the admin panel's language, looked up by their English
1745
+ * wording, so a site's own blocks that word theirs the same way are
1746
+ * translated too. Only what the editor shows changes: props, choices' values
1747
+ * and what blocks render stay as they are.
1748
+ */
1749
+ function withLabels(config, labels) {
1750
+ if (Object.keys(labels).length === 0) return config;
1751
+ const components = Object.fromEntries(Object.entries(config.components).map(([name, component]) => [name, withComponentLabels(component, labels)]));
1752
+ const categories = config.categories && Object.fromEntries(Object.entries(config.categories).map(([name, category]) => [name, {
1753
+ ...category,
1754
+ ...category.title !== void 0 && { title: translated(labels, category.title) }
1755
+ }]));
1756
+ return {
1757
+ ...config,
1758
+ components,
1759
+ ...categories && { categories },
1760
+ ...config.root && { root: withComponentLabels(config.root, labels) }
1761
+ };
1762
+ }
1763
+ //#endregion
1764
+ //#region src/frame/owners-only.tsx
1765
+ /**
1766
+ * Block settings only owners may change (`metadata: { owners: true }`, such as
1767
+ * Custom HTML used exactly as written), shown to editors on full-stack sites
1768
+ * as they are, without a way to change them. Their server refuses such a
1769
+ * change anyway (`ownersOnlyChanges()`); this only says so up front.
1770
+ */
1771
+ function valueLabel(field, value) {
1772
+ const option = ("options" in field && Array.isArray(field.options) ? field.options : []).find((choice) => JSON.stringify(choice.value) === JSON.stringify(value));
1773
+ if (option) return String(option.label);
1774
+ return value === void 0 || value === null ? "" : String(value);
1775
+ }
1776
+ function LockedField({ field, value }) {
1777
+ const t = useStrings();
1778
+ return /* @__PURE__ */ jsxs("div", {
1779
+ className: "gfa-locked-field",
1780
+ children: [
1781
+ field.label && /* @__PURE__ */ jsx("div", {
1782
+ className: "gfa-locked-field-label",
1783
+ children: field.label
1784
+ }),
1785
+ /* @__PURE__ */ jsx("div", {
1786
+ className: "gfa-locked-field-value",
1787
+ children: valueLabel(field, value)
1788
+ }),
1789
+ /* @__PURE__ */ jsx("p", {
1790
+ className: "gfa-locked-field-note",
1791
+ children: t("owners.only")
1792
+ })
1793
+ ]
1794
+ });
1795
+ }
1796
+ function locked(fields) {
1797
+ return Object.fromEntries(Object.entries(fields).map(([name, field]) => {
1798
+ if (!isOwnersOnlyField(field)) return [name, field];
1799
+ return [name, {
1800
+ type: "custom",
1801
+ ...field.label !== void 0 && { label: field.label },
1802
+ ...field.metadata !== void 0 && { metadata: field.metadata },
1803
+ render: ({ value }) => /* @__PURE__ */ jsx(LockedField, {
1804
+ field,
1805
+ value
1806
+ })
1807
+ }];
1808
+ }));
1809
+ }
1810
+ /** The config with owners-only settings shown but not changeable, for editors who aren't owners. */
1811
+ function withOwnersOnlyLocked(config) {
1812
+ const components = Object.fromEntries(Object.entries(config.components).map(([name, component]) => {
1813
+ const fields = component.fields ?? {};
1814
+ if (!Object.values(fields).some(isOwnersOnlyField)) return [name, component];
1815
+ return [name, {
1816
+ ...component,
1817
+ fields: locked(fields)
1818
+ }];
1819
+ }));
1820
+ return {
1821
+ ...config,
1822
+ components
1823
+ };
1824
+ }
1825
+ //#endregion
1375
1826
  //#region src/frame/page-settings.ts
1827
+ /** What each setting is where a page leaves it out, which the editor shows as chosen. */
1828
+ const SHOWN_DEFAULTS = {
1829
+ noIndex: false,
1830
+ noFollow: false,
1831
+ noSnippet: false,
1832
+ card: "auto"
1833
+ };
1376
1834
  /** For pages and templates, whose settings include `noIndex`; `path` is the page's, for pages. */
1377
1835
  function shownSettings(original, path) {
1378
1836
  const fixed = path !== void 0 && neverIndexed(path);
1379
1837
  const originalRoot = original.root;
1380
- const originalValue = originalRoot.props?.noIndex;
1838
+ const originalProps = originalRoot.props ?? {};
1381
1839
  return {
1382
1840
  show(data) {
1383
1841
  const root = data.root;
1842
+ const missing = Object.entries(SHOWN_DEFAULTS).filter(([name]) => root.props?.[name] === void 0);
1843
+ if (missing.length === 0 && !fixed) return data;
1844
+ const props = {
1845
+ ...root.props,
1846
+ ...Object.fromEntries(missing)
1847
+ };
1384
1848
  if (fixed) return {
1385
1849
  ...data,
1386
1850
  root: {
1387
1851
  ...root,
1388
1852
  props: {
1389
- ...root.props,
1853
+ ...props,
1390
1854
  noIndex: true
1391
1855
  },
1392
1856
  readOnly: {
@@ -1395,32 +1859,29 @@ function shownSettings(original, path) {
1395
1859
  }
1396
1860
  }
1397
1861
  };
1398
- if (root.props?.noIndex !== void 0) return data;
1399
1862
  return {
1400
1863
  ...data,
1401
1864
  root: {
1402
1865
  ...root,
1403
- props: {
1404
- ...root.props,
1405
- noIndex: false
1406
- }
1866
+ props
1407
1867
  }
1408
1868
  };
1409
1869
  },
1410
1870
  keep(data) {
1411
1871
  const root = data.root;
1412
- const { noIndex, ...props } = root.props ?? {};
1413
- const kept = fixed || originalValue === void 0 && noIndex === false ? originalValue : noIndex;
1872
+ const props = { ...root.props };
1873
+ for (const [name, value] of Object.entries(SHOWN_DEFAULTS)) {
1874
+ const kept = fixed && name === "noIndex" || originalProps[name] === void 0 && props[name] === value ? originalProps[name] : props[name];
1875
+ if (kept === void 0) delete props[name];
1876
+ else props[name] = kept;
1877
+ }
1414
1878
  const { readOnly: _shown, ...rest } = root;
1415
1879
  const readOnly = fixed ? originalRoot.readOnly : root.readOnly;
1416
1880
  return {
1417
1881
  ...data,
1418
1882
  root: {
1419
1883
  ...rest,
1420
- props: kept === void 0 ? props : {
1421
- ...props,
1422
- noIndex: kept
1423
- },
1884
+ props,
1424
1885
  ...readOnly && { readOnly }
1425
1886
  }
1426
1887
  };
@@ -1432,8 +1893,8 @@ function shownSettings(original, path) {
1432
1893
  /**
1433
1894
  * A block's Style field (`classNameField` in `@goodfellow-cms/react`), which
1434
1895
  * Puck's editor shows as controls for its look in place of a box of classes:
1435
- * text alignment, size and color, background, space inside and around it and
1436
- * rounded corners, written into its `className` as Tailwind classes
1896
+ * text alignment, size and color, background, space inside and around it,
1897
+ * rounded corners and the sizes of screen it's hidden on, written into its `className` as Tailwind classes
1437
1898
  * (`withBlockStyle()` in `@goodfellow-cms/core`), with any other classes kept.
1438
1899
  */
1439
1900
  /** A color's value to show and compare: a theme color's, or the editor's own. */
@@ -1627,6 +2088,36 @@ function ColorChoice({ label, value, colors, readOnly, onChange }) {
1627
2088
  })]
1628
2089
  });
1629
2090
  }
2091
+ /** The sizes of screen a block is hidden on, a box for each. */
2092
+ function HiddenOnChoice({ hiddenOn, readOnly, onChange }) {
2093
+ const t = useStrings();
2094
+ const hidden = new Set(hiddenOn);
2095
+ return /* @__PURE__ */ jsxs("fieldset", {
2096
+ className: "gfa-style-group",
2097
+ children: [
2098
+ /* @__PURE__ */ jsx("legend", {
2099
+ className: "gfa-style-label",
2100
+ children: t("style.hiddenOn")
2101
+ }),
2102
+ SCREEN_SIZES.map((size) => /* @__PURE__ */ jsxs("label", {
2103
+ className: "gfa-style-check",
2104
+ children: [/* @__PURE__ */ jsx("input", {
2105
+ type: "checkbox",
2106
+ checked: hidden.has(size),
2107
+ disabled: readOnly,
2108
+ onChange: (event) => {
2109
+ const next = SCREEN_SIZES.filter((candidate) => candidate === size ? event.target.checked : hidden.has(candidate));
2110
+ onChange(next.length > 0 ? next : void 0);
2111
+ }
2112
+ }), t(`style.hiddenOn.${size}`)]
2113
+ }, size)),
2114
+ hidden.size > 0 && /* @__PURE__ */ jsx("p", {
2115
+ className: "gfa-hint",
2116
+ children: t(hidden.size === SCREEN_SIZES.length ? "style.hiddenOnAll" : "style.hiddenOnHint")
2117
+ })
2118
+ ]
2119
+ });
2120
+ }
1630
2121
  /** Other classes, kept as typed while the box has focus, so a class isn't taken into the controls halfway. */
1631
2122
  function OtherClasses({ other, readOnly, onChange }) {
1632
2123
  const t = useStrings();
@@ -1665,7 +2156,7 @@ function OtherClasses({ other, readOnly, onChange }) {
1665
2156
  ]
1666
2157
  });
1667
2158
  }
1668
- function StyleControls({ value, colors, readOnly, label, ownAlign, onChange }) {
2159
+ function StyleControls({ value, colors, readOnly, label, ownAlign, block, onChange }) {
1669
2160
  const t = useStrings();
1670
2161
  const { style, other } = readBlockStyle(value);
1671
2162
  const set = (next) => onChange(withBlockStyle(other, next));
@@ -1750,6 +2241,11 @@ function StyleControls({ value, colors, readOnly, label, ownAlign, onChange }) {
1750
2241
  readOnly,
1751
2242
  onChange: (radius) => change("radius", radius)
1752
2243
  }),
2244
+ (block || style.hiddenOn) && /* @__PURE__ */ jsx(HiddenOnChoice, {
2245
+ hiddenOn: style.hiddenOn,
2246
+ readOnly,
2247
+ onChange: (hiddenOn) => change("hiddenOn", hiddenOn)
2248
+ }),
1753
2249
  /* @__PURE__ */ jsx(OtherClasses, {
1754
2250
  other,
1755
2251
  readOnly,
@@ -1765,7 +2261,7 @@ function StyleControls({ value, colors, readOnly, label, ownAlign, onChange }) {
1765
2261
  ]
1766
2262
  });
1767
2263
  }
1768
- function withStyleField(field, colors, ownAlign) {
2264
+ function withStyleField(field, colors, ownAlign, block) {
1769
2265
  if (field.type !== "text" || !isStyleField(field)) return field;
1770
2266
  const render = ({ value, onChange, readOnly }) => /* @__PURE__ */ jsx(StyleControls, {
1771
2267
  value,
@@ -1773,6 +2269,7 @@ function withStyleField(field, colors, ownAlign) {
1773
2269
  readOnly,
1774
2270
  label: field.label,
1775
2271
  ownAlign,
2272
+ block,
1776
2273
  onChange
1777
2274
  });
1778
2275
  return {
@@ -1782,27 +2279,96 @@ function withStyleField(field, colors, ownAlign) {
1782
2279
  render
1783
2280
  };
1784
2281
  }
1785
- function withStyleFieldsIn(fields, colors) {
2282
+ function withStyleFieldsIn(fields, colors, block) {
1786
2283
  if (!fields) return fields;
1787
2284
  const ownAlign = "align" in fields;
1788
- return Object.fromEntries(Object.entries(fields).map(([name, field]) => [name, withStyleField(field, colors, ownAlign)]));
2285
+ return Object.fromEntries(Object.entries(fields).map(([name, field]) => [name, withStyleField(field, colors, ownAlign, block)]));
1789
2286
  }
1790
- function withStyleComponent(component, colors) {
2287
+ function withStyleComponent(component, colors, block) {
1791
2288
  if (!component) return component;
1792
2289
  const { resolveFields } = component;
1793
2290
  return {
1794
2291
  ...component,
1795
- ...component.fields && { fields: withStyleFieldsIn(component.fields, colors) },
1796
- ...resolveFields && { resolveFields: async (...args) => withStyleFieldsIn(await resolveFields(...args), colors) }
2292
+ ...component.fields && { fields: withStyleFieldsIn(component.fields, colors, block) },
2293
+ ...resolveFields && { resolveFields: async (...args) => withStyleFieldsIn(await resolveFields(...args), colors, block) }
1797
2294
  };
1798
2295
  }
1799
2296
  /** The config with every block's Style field, and the page's, drawn as a style's controls. */
1800
2297
  function withStyleFields(config, colors) {
1801
- const components = Object.fromEntries(Object.entries(config.components).map(([name, component]) => [name, withStyleComponent(component, colors)]));
2298
+ const components = Object.fromEntries(Object.entries(config.components).map(([name, component]) => [name, withStyleComponent(component, colors, true)]));
1802
2299
  return {
1803
2300
  ...config,
1804
2301
  components,
1805
- ...config.root && { root: withStyleComponent(config.root, colors) }
2302
+ ...config.root && { root: withStyleComponent(config.root, colors, false) }
2303
+ };
2304
+ }
2305
+ //#endregion
2306
+ //#region src/frame/synced-block.tsx
2307
+ /**
2308
+ * Synced blocks on the editor's canvas: a placed Synced block draws the
2309
+ * synced block's own blocks in its place, read-only, with a link to edit it,
2310
+ * since they're kept once for every page that places them. Builds put them
2311
+ * in place before rendering (`expandSyncedBlocks()`); the editor keeps the
2312
+ * placement, which is what the page saves.
2313
+ */
2314
+ function SyncedBlockDrawing({ name, config, drawing }) {
2315
+ const t = useStrings();
2316
+ const block = name ? findSyncedBlock(drawing.content, name) : void 0;
2317
+ if (!block) return /* @__PURE__ */ jsx("div", {
2318
+ className: "gfa-synced-block gfa-synced-block-missing",
2319
+ children: /* @__PURE__ */ jsx("p", { children: name ? t("synced.missing", { name }) : t("synced.choose") })
2320
+ });
2321
+ const shown = syncedBlockIn(block, drawing.language);
2322
+ const href = syncedBlockEditorHref(block.name, drawing.main ? void 0 : drawing.language);
2323
+ const data = {
2324
+ root: { props: {} },
2325
+ content: shown.data.content
2326
+ };
2327
+ return /* @__PURE__ */ jsxs("div", {
2328
+ className: "gfa-synced-block",
2329
+ "data-gf-synced-block": block.name,
2330
+ children: [/* @__PURE__ */ jsxs("div", {
2331
+ className: "gfa-synced-block-bar",
2332
+ children: [/* @__PURE__ */ jsx("span", { children: t("synced.label", { title: shown.title }) }), /* @__PURE__ */ jsx("a", {
2333
+ href,
2334
+ ref: (element) => registerOverlayPortal(element, { disableDrag: true }),
2335
+ onClick: (event) => {
2336
+ event.preventDefault();
2337
+ drawing.navigate(href);
2338
+ },
2339
+ children: t("synced.edit")
2340
+ })]
2341
+ }), /* @__PURE__ */ jsx("div", {
2342
+ inert: true,
2343
+ children: /* @__PURE__ */ jsx(Render, {
2344
+ config,
2345
+ data,
2346
+ metadata: drawing.metadata
2347
+ })
2348
+ })]
2349
+ });
2350
+ }
2351
+ /**
2352
+ * The config with its Synced block drawing the synced block it places, from
2353
+ * the blocks of the same config, whose own Synced block draws nothing, since
2354
+ * a synced block can't place another.
2355
+ */
2356
+ function withSyncedBlockDrawing(config, drawing) {
2357
+ const component = config.components[SYNCED_BLOCK];
2358
+ if (!component) return config;
2359
+ return {
2360
+ ...config,
2361
+ components: {
2362
+ ...config.components,
2363
+ [SYNCED_BLOCK]: {
2364
+ ...component,
2365
+ render: (props) => /* @__PURE__ */ jsx(SyncedBlockDrawing, {
2366
+ name: typeof props[SYNCED_BLOCK_PROP] === "string" ? props[SYNCED_BLOCK_PROP] : "",
2367
+ config,
2368
+ drawing
2369
+ })
2370
+ }
2371
+ }
1806
2372
  };
1807
2373
  }
1808
2374
  //#endregion
@@ -1810,12 +2376,157 @@ function withStyleFields(config, colors) {
1810
2376
  /**
1811
2377
  * The grid of cells a table's field is edited in (`tableField()` in
1812
2378
  * `@goodfellow-cms/react`), which Puck's editor shows in place of the field's
1813
- * own render, since blocks can't bring their own editing code.
2379
+ * own render, since blocks can't bring their own editing code. Each cell is
2380
+ * a small box of formatted text: bold, italics, links and line breaks, with
2381
+ * what's pasted kept to those (`tableCellHtml()`).
1814
2382
  */
1815
2383
  const MAX_ROWS = 100;
1816
2384
  const MAX_COLUMNS = 12;
2385
+ /** One cell, as formatted text. What it holds comes from the cell's value, except while it's being typed in. */
2386
+ function RichCell({ value, label, readOnly, onChange, onFocus }) {
2387
+ const ref = useRef(null);
2388
+ useLayoutEffect(() => {
2389
+ const cell = ref.current;
2390
+ if (!cell || document.activeElement === cell) return;
2391
+ const html = tableCellHtml(value);
2392
+ if (cell.innerHTML !== html) cell.innerHTML = html;
2393
+ }, [value]);
2394
+ const changed = () => {
2395
+ if (ref.current) onChange(tableCellHtml(ref.current.innerHTML));
2396
+ };
2397
+ return /* @__PURE__ */ jsx("div", {
2398
+ ref,
2399
+ role: "textbox",
2400
+ tabIndex: 0,
2401
+ "aria-label": label,
2402
+ "aria-multiline": "true",
2403
+ "aria-readonly": readOnly || void 0,
2404
+ contentEditable: !readOnly,
2405
+ suppressContentEditableWarning: true,
2406
+ className: "gfa-input gfa-table-field-cell gfa-table-field-rich",
2407
+ onInput: changed,
2408
+ onFocus: () => ref.current && onFocus(ref.current),
2409
+ onKeyDown: (event) => {
2410
+ if (event.key === "Enter") {
2411
+ event.preventDefault();
2412
+ document.execCommand("insertLineBreak");
2413
+ } else if ((event.ctrlKey || event.metaKey) && (event.key === "b" || event.key === "i")) {
2414
+ event.preventDefault();
2415
+ document.execCommand(event.key === "b" ? "bold" : "italic");
2416
+ }
2417
+ },
2418
+ onPaste: (event) => {
2419
+ event.preventDefault();
2420
+ const html = event.clipboardData.getData("text/html");
2421
+ const text = event.clipboardData.getData("text/plain");
2422
+ document.execCommand("insertHTML", false, html ? tableCellHtml(html) : escapeCellText(text));
2423
+ }
2424
+ });
2425
+ }
2426
+ /** Bold, italics and links for the cell last typed in. */
2427
+ function CellToolbar({ cell, readOnly }) {
2428
+ const t = useStrings();
2429
+ const [linking, setLinking] = useState();
2430
+ const disabled = readOnly || !cell;
2431
+ const command = (name, argument) => {
2432
+ cell?.focus();
2433
+ document.execCommand(name, false, argument);
2434
+ };
2435
+ const startLink = () => {
2436
+ const selection = document.getSelection();
2437
+ const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : void 0;
2438
+ setLinking({
2439
+ range: range && cell?.contains(range.commonAncestorContainer) ? range : void 0,
2440
+ address: ""
2441
+ });
2442
+ };
2443
+ const applyLink = (event) => {
2444
+ event.preventDefault();
2445
+ const address = linking?.address.trim() ?? "";
2446
+ if (cell && linking?.range && address && isSafeUrl(address)) {
2447
+ cell.focus();
2448
+ const selection = document.getSelection();
2449
+ selection?.removeAllRanges();
2450
+ selection?.addRange(linking.range);
2451
+ document.execCommand("createLink", false, address);
2452
+ }
2453
+ setLinking(void 0);
2454
+ };
2455
+ const keep = (event) => event.preventDefault();
2456
+ return /* @__PURE__ */ jsxs("div", {
2457
+ className: "gfa-table-field-toolbar",
2458
+ children: [
2459
+ /* @__PURE__ */ jsx("button", {
2460
+ type: "button",
2461
+ className: "gfa-table-field-tool",
2462
+ disabled,
2463
+ onMouseDown: keep,
2464
+ onClick: () => command("bold"),
2465
+ children: /* @__PURE__ */ jsx("strong", { children: t("table.bold") })
2466
+ }),
2467
+ /* @__PURE__ */ jsx("button", {
2468
+ type: "button",
2469
+ className: "gfa-table-field-tool",
2470
+ disabled,
2471
+ onMouseDown: keep,
2472
+ onClick: () => command("italic"),
2473
+ children: /* @__PURE__ */ jsx("em", { children: t("table.italic") })
2474
+ }),
2475
+ /* @__PURE__ */ jsx("button", {
2476
+ type: "button",
2477
+ className: "gfa-table-field-tool",
2478
+ disabled,
2479
+ onMouseDown: keep,
2480
+ onClick: startLink,
2481
+ children: t("table.link")
2482
+ }),
2483
+ /* @__PURE__ */ jsx("button", {
2484
+ type: "button",
2485
+ className: "gfa-table-field-tool",
2486
+ disabled,
2487
+ onMouseDown: keep,
2488
+ onClick: () => command("unlink"),
2489
+ children: t("table.unlink")
2490
+ }),
2491
+ linking && /* @__PURE__ */ jsxs("form", {
2492
+ className: "gfa-table-field-link",
2493
+ onSubmit: applyLink,
2494
+ children: [
2495
+ /* @__PURE__ */ jsx("input", {
2496
+ className: "gfa-input",
2497
+ "aria-label": t("table.linkAddress"),
2498
+ placeholder: "/about",
2499
+ value: linking.address,
2500
+ autoFocus: true,
2501
+ onChange: (event) => setLinking({
2502
+ ...linking,
2503
+ address: event.target.value
2504
+ })
2505
+ }),
2506
+ /* @__PURE__ */ jsx("button", {
2507
+ type: "submit",
2508
+ className: "gfa-button gfa-button-secondary",
2509
+ disabled: !linking.range,
2510
+ children: t("table.linkApply")
2511
+ }),
2512
+ /* @__PURE__ */ jsx("button", {
2513
+ type: "button",
2514
+ className: "gfa-button gfa-button-secondary",
2515
+ onClick: () => setLinking(void 0),
2516
+ children: t("action.cancel")
2517
+ }),
2518
+ !linking.range && /* @__PURE__ */ jsx("p", {
2519
+ className: "gfa-hint",
2520
+ children: t("table.linkSelect")
2521
+ })
2522
+ ]
2523
+ })
2524
+ ]
2525
+ });
2526
+ }
1817
2527
  function TableCellsField({ value, onChange, readOnly, label }) {
1818
2528
  const t = useStrings();
2529
+ const [active, setActive] = useState();
1819
2530
  const rows = tableCells(value);
1820
2531
  const width = rows[0]?.length ?? 1;
1821
2532
  const set = (row, column, text) => onChange(rows.map((cells, r) => r === row ? cells.map((cell, c) => c === column ? text : cell) : cells));
@@ -1826,20 +2537,24 @@ function TableCellsField({ value, onChange, readOnly, label }) {
1826
2537
  className: "gfa-table-field-label",
1827
2538
  children: label
1828
2539
  }),
2540
+ /* @__PURE__ */ jsx(CellToolbar, {
2541
+ cell: active?.isConnected ? active : void 0,
2542
+ readOnly
2543
+ }),
1829
2544
  /* @__PURE__ */ jsxs("div", {
1830
2545
  className: "gfa-table-field-grid",
1831
2546
  style: { gridTemplateColumns: `repeat(${width}, minmax(6rem, 1fr)) auto` },
1832
2547
  children: [rows.map((cells, row) => /* @__PURE__ */ jsxs("div", {
1833
2548
  className: "gfa-table-field-row",
1834
- children: [cells.map((cell, column) => /* @__PURE__ */ jsx("input", {
1835
- className: "gfa-input gfa-table-field-cell",
2549
+ children: [cells.map((cell, column) => /* @__PURE__ */ jsx(RichCell, {
1836
2550
  value: cell,
1837
2551
  readOnly,
1838
- "aria-label": t("table.cell", {
2552
+ label: t("table.cell", {
1839
2553
  row: String(row + 1),
1840
2554
  column: String(column + 1)
1841
2555
  }),
1842
- onChange: (event) => set(row, column, event.target.value)
2556
+ onChange: (html) => set(row, column, html),
2557
+ onFocus: setActive
1843
2558
  }, column)), /* @__PURE__ */ jsx("button", {
1844
2559
  type: "button",
1845
2560
  className: "gfa-table-field-remove",
@@ -1932,7 +2647,8 @@ const ADMIN_PLACES = {
1932
2647
  "contact-settings": `#/settings/general?section=${SETTINGS_SECTIONS.contact}`,
1933
2648
  "forms-settings": `#/settings/general?section=${SETTINGS_SECTIONS.forms}`,
1934
2649
  "theme-settings": "#/settings/theme",
1935
- "language-settings": "#/settings/languages"
2650
+ "language-settings": "#/settings/languages",
2651
+ "comments-settings": "#/settings/comments"
1936
2652
  };
1937
2653
  /** Puck's text field, with the media library's chooser below it for fields that hold a media address. */
1938
2654
  function TextFieldWithMedia({ field, value, onChange, readOnly, children }) {
@@ -1969,9 +2685,12 @@ function canvasConfig(kind, config, layoutConfig, header, footer, site) {
1969
2685
  children: kind === "header" ? /* @__PURE__ */ jsx("header", {
1970
2686
  className: "gf-header",
1971
2687
  children
1972
- }) : /* @__PURE__ */ jsx("footer", {
2688
+ }) : kind === "footer" ? /* @__PURE__ */ jsx("footer", {
1973
2689
  className: "gf-footer",
1974
2690
  children
2691
+ }) : /* @__PURE__ */ jsx("main", {
2692
+ className: "gf-main",
2693
+ children
1975
2694
  })
1976
2695
  });
1977
2696
  return {
@@ -2091,6 +2810,13 @@ function Bridge({ channel, settings }) {
2091
2810
  content: [...previous.content, ...change.blocks]
2092
2811
  })
2093
2812
  });
2813
+ else if (change.type === "detach") {
2814
+ if (!getSelectorForId(change.id)) throw new Error("The block being changed is no longer on the page.");
2815
+ dispatch({
2816
+ type: "setData",
2817
+ data: (previous) => withBlocksInPlace(previous, change.id, change.blocks)
2818
+ });
2819
+ }
2094
2820
  })
2095
2821
  ];
2096
2822
  return () => {
@@ -2120,7 +2846,7 @@ const READ_ONLY = {
2120
2846
  };
2121
2847
  function FrameEditor({ open, onOpened }) {
2122
2848
  const client = useAdminClient();
2123
- const { config: siteConfig, pageConfig, layoutConfig, templateConfig } = useFrameCode();
2849
+ const { config: siteConfig, pageConfig, layoutConfig, templateConfig, syncedConfig } = useFrameCode();
2124
2850
  const { preview, mediaPreviews } = useAdmin();
2125
2851
  const { content: loaded } = useSiteContent();
2126
2852
  const { kind, path, title, data } = open;
@@ -2147,7 +2873,7 @@ function FrameEditor({ open, onOpened }) {
2147
2873
  loaded.settings.theme,
2148
2874
  loaded.customCss,
2149
2875
  dark
2150
- ]));
2876
+ ]), mediaPreviews);
2151
2877
  useEffect(() => showMediaInPreview(document, mediaPreviews), [mediaPreviews]);
2152
2878
  useEffect(() => {
2153
2879
  const onClick = (event) => {
@@ -2180,9 +2906,13 @@ function FrameEditor({ open, onOpened }) {
2180
2906
  siteConfig.blocks
2181
2907
  ]);
2182
2908
  const [view, setView] = useState(() => viewOf(initialData));
2909
+ const headingsOf = useCallback((next) => kind === "entry" || kind === "header" || kind === "footer" ? [] : pageHeadings(next, { components: siteConfig.blocks }), [kind, siteConfig.blocks]);
2910
+ const [headings, setHeadings] = useState(() => headingsOf(initialData));
2183
2911
  const updateView = (next) => {
2184
2912
  const found = viewOf(next);
2185
2913
  setView((current) => current?.block === found?.block ? current : found);
2914
+ const listed = headingsOf(next);
2915
+ setHeadings((current) => JSON.stringify(current) === JSON.stringify(listed) ? current : listed);
2186
2916
  };
2187
2917
  const adminLink = useCallback((place) => {
2188
2918
  const href = ADMIN_PLACES[place];
@@ -2195,6 +2925,16 @@ function FrameEditor({ open, onOpened }) {
2195
2925
  }
2196
2926
  };
2197
2927
  }, [client]);
2928
+ useEffect(() => {
2929
+ const onKeyDown = (event) => {
2930
+ const shortcut = shortcutFor(event, isTyping(event.target));
2931
+ if (!isForwardedShortcut(shortcut)) return;
2932
+ event.preventDefault();
2933
+ client.call("shortcut", shortcut).catch(() => {});
2934
+ };
2935
+ document.addEventListener("keydown", onKeyDown, true);
2936
+ return () => document.removeEventListener("keydown", onKeyDown, true);
2937
+ }, [client]);
2198
2938
  const callPlugin = useCallback((plugin, method, ...args) => client.call("plugin.call", {
2199
2939
  plugin,
2200
2940
  method,
@@ -2208,10 +2948,14 @@ function FrameEditor({ open, onOpened }) {
2208
2948
  pages: pageLinks(content),
2209
2949
  collection,
2210
2950
  ...view && { view },
2951
+ ...headings.length > 0 && { headings },
2211
2952
  ...languages && {
2212
2953
  languages,
2213
2954
  home: languageHome(loaded, language)
2214
2955
  },
2956
+ ...content.descriptions && { descriptions: Object.fromEntries(Object.entries(content.descriptions).map(([url, description]) => [url, describedIn(description, content.settings.language)])) },
2957
+ ...content.focus && { focus: Object.fromEntries(Object.entries(content.focus).map(([url, point]) => [url, focusPosition(point)])) },
2958
+ ...loaded.syncedBlocks && { syncedBlocks: loaded.syncedBlocks },
2215
2959
  adminLink,
2216
2960
  callPlugin
2217
2961
  }), [
@@ -2221,35 +2965,59 @@ function FrameEditor({ open, onOpened }) {
2221
2965
  path,
2222
2966
  collection,
2223
2967
  view,
2968
+ headings,
2224
2969
  languages,
2225
2970
  adminLink,
2226
2971
  callPlugin
2227
2972
  ]);
2228
2973
  const entry = kind === "entry" && collection && open.entry ? findEntry(loaded, collection.id, open.entry) : void 0;
2974
+ const { puck: dictionary, labels } = useEditorText();
2975
+ const metadata = useMemo(() => siteMetadata(site), [site]);
2976
+ const drawn = useCallback((config) => withSyncedBlockDrawing(config, {
2977
+ content: loaded,
2978
+ language,
2979
+ main: language === mainLanguage(loaded.settings),
2980
+ metadata,
2981
+ navigate: (href) => void client.call("navigate", href)
2982
+ }), [
2983
+ loaded,
2984
+ language,
2985
+ metadata,
2986
+ client
2987
+ ]);
2229
2988
  const config = useMemo(() => {
2230
2989
  if (kind === "entry") {
2231
2990
  if (!collection || !entry) throw new Error("The item isn't in the site.");
2232
- return entryConfig(collection, entry.entry, content, {
2991
+ return withLabels(entryConfig(collection, entry.entry, content, {
2233
2992
  templateConfig,
2234
2993
  layoutConfig
2235
- });
2994
+ }), labels);
2236
2995
  }
2237
- return withTableFields(canvasConfig(kind, kind === "page" ? pageConfig : kind === "template" ? templateConfig : layoutConfig, layoutConfig, content.header.data, content.footer.data, site));
2996
+ return withLabels(withTableFields(canvasConfig(kind, drawn(kind === "page" ? pageConfig : kind === "template" ? templateConfig : kind === "synced" ? syncedConfig : layoutConfig), layoutConfig, expandSyncedBlocks(content.header.data, loaded, language), expandSyncedBlocks(content.footer.data, loaded, language), site)), labels);
2238
2997
  }, [
2239
2998
  kind,
2240
2999
  collection,
2241
3000
  entry,
2242
3001
  pageConfig,
2243
3002
  templateConfig,
3003
+ syncedConfig,
2244
3004
  layoutConfig,
2245
3005
  content,
2246
- site
3006
+ loaded,
3007
+ language,
3008
+ site,
3009
+ labels,
3010
+ drawn
2247
3011
  ]);
2248
3012
  const described = useMemo(() => describeConfig(config), [config]);
2249
- const puckConfig = useMemo(() => kind === "entry" ? config : withStyleFields(config, content.settings.theme.colors), [
3013
+ const puckConfig = useMemo(() => {
3014
+ const styled = kind === "entry" ? config : withStyleFields(config, content.settings.theme.colors);
3015
+ return open.ownersOnlyLocked ? withOwnersOnlyLocked(styled) : styled;
3016
+ }, [
2250
3017
  kind,
2251
3018
  config,
2252
- content.settings.theme.colors
3019
+ content.settings.theme.colors,
3020
+ open.ownersOnlyLocked
2253
3021
  ]);
2254
3022
  const told = useRef(void 0);
2255
3023
  useEffect(() => {
@@ -2305,8 +3073,9 @@ function FrameEditor({ open, onOpened }) {
2305
3073
  children: /* @__PURE__ */ jsx(Puck, {
2306
3074
  config: puckConfig,
2307
3075
  data: initialData,
2308
- metadata: siteMetadata(site),
3076
+ metadata,
2309
3077
  headerTitle: title,
3078
+ dictionary,
2310
3079
  height: "100%",
2311
3080
  iframe: { enabled: false },
2312
3081
  ui,
@@ -2414,7 +3183,8 @@ function CodeService({ channel }) {
2414
3183
  channel.handle("code.sitePages", ({ content, today }) => code.sitePages(content, today)),
2415
3184
  channel.handle("code.pageView", async ({ page, content, today }) => await code.pageView(page, content, today) ?? null),
2416
3185
  channel.handle("code.renderedProblems", ({ content, targets, today }) => code.renderedProblems(content, targets, today)),
2417
- channel.handle("code.drawBlock", ({ content, target }) => code.drawBlock(content, target))
3186
+ channel.handle("code.drawBlock", ({ content, target }) => code.drawBlock(content, target)),
3187
+ channel.handle("code.blockFields", ({ content, target }) => code.blockFields(content, target))
2418
3188
  ];
2419
3189
  return () => {
2420
3190
  for (const stop of stops) stop();
@@ -2429,7 +3199,8 @@ function editorKey(open) {
2429
3199
  open.path,
2430
3200
  open.collection,
2431
3201
  open.entry,
2432
- open.language
3202
+ open.language,
3203
+ open.reopened
2433
3204
  ]);
2434
3205
  }
2435
3206
  /** Opens an editor when the admin panel's page asks, and tells it Puck's config once it's open. */