@goodfellow-cms/admin 0.5.0 → 0.7.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,10 +1,12 @@
1
- import { A as MediaChoiceContext, Ft as editedMarkdown, It as formattedHtml, Mt as storedEntryFields, O as createMediaPreviews, Q as StringsProvider, U as createChannel, V as READY_MESSAGE, a as AdminContext, at as mediaUrl, f as useDarkMode, h as ScheduleFields, i as usePreviewStyles, j as MediaChooser, jt as storedData, k as showMediaInPreview, l as useSiteContent, rt as findPage, s as useAdmin, t as SCHEDULE_PROP, tt as useStrings, u as setThemeChoice, y as SETTINGS_SECTIONS } from "./entry-values-ocbalc9U.js";
2
- import { COLOR_SCHEME_ATTRIBUTE, MEDIA_DIR, WEEKDAYS, allPages, canFormatMarkdown, cleanEventValue, contentInLanguage, datePart, escapeStyleText, findEntry, formatDate, formatOccurrence, getPageHead, googleFontsUrl, hasLanguages, isAllDay, isEventLike, languageHome, languageLinks, mainLanguage, neverIndexed, pageView, pathLanguage, renderedProblems, shownOccurrence, sitePages, themeToCss, todayIn, wallClock, weekdayName, weekdayOf } from "@goodfellow-cms/core";
3
- import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
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";
4
5
  import { createRoot } from "react-dom/client";
5
6
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
- import { PageBody, SiteProvider, adminSite, applyEntry, createPuckConfig, createPuckConfigs, cx, mediaFieldKind, preparePage, siteMetadata } from "@goodfellow-cms/react";
7
- import { AutoField, FieldLabel, Puck, Render, RichTextMenu, createUsePuck, legacySideBarPlugin, migrate, registerOverlayPortal, resolveAllData, useGetPuck } from "@puckeditor/core";
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 { AutoField, Drawer, FieldLabel, Puck, Render, RichTextMenu, createUsePuck, legacySideBarPlugin, migrate, registerOverlayPortal, resolveAllData, useGetPuck } from "@puckeditor/core";
8
10
  import { createPortal, flushSync } from "react-dom";
9
11
  const CANVAS = `.gf-canvas`;
10
12
  const CANVAS_CONTAINER = "gf-canvas";
@@ -298,6 +300,128 @@ function SharePreview({ path }) {
298
300
  });
299
301
  }
300
302
  //#endregion
303
+ //#region src/frame/block-icon.tsx
304
+ /** A block's icon, at the block list's size, in the text's color. */
305
+ function BlockIconSvg({ icon }) {
306
+ if (!icon) return /* @__PURE__ */ jsx(Box, {
307
+ className: "gfa-block-icon",
308
+ size: 16,
309
+ strokeWidth: 2,
310
+ "aria-hidden": "true"
311
+ });
312
+ return /* @__PURE__ */ jsx("svg", {
313
+ className: "gfa-block-icon",
314
+ xmlns: "http://www.w3.org/2000/svg",
315
+ width: "16",
316
+ height: "16",
317
+ viewBox: "0 0 24 24",
318
+ fill: "none",
319
+ stroke: "currentColor",
320
+ strokeWidth: "2",
321
+ strokeLinecap: "round",
322
+ strokeLinejoin: "round",
323
+ "aria-hidden": "true",
324
+ children: icon.map(([shape, attributes], index) => createElement(shape, {
325
+ key: index,
326
+ ...attributes
327
+ }))
328
+ });
329
+ }
330
+ /** Puck's block list item, with the block's icon before its name. */
331
+ function drawerItemWithIcon(icons) {
332
+ return function DrawerItemWithIcon({ children, name }) {
333
+ return /* @__PURE__ */ jsxs("div", {
334
+ className: "gfa-drawer-item",
335
+ children: [/* @__PURE__ */ jsx(BlockIconSvg, { icon: icons.get(name) }), children]
336
+ });
337
+ };
338
+ }
339
+ //#endregion
340
+ //#region src/frame/block-search.tsx
341
+ /** The blocks of a Puck config, with their groups and the site's keywords for them, in the list's order. */
342
+ function searchableBlocks(config, siteConfig) {
343
+ const groups = /* @__PURE__ */ new Map();
344
+ const order = [];
345
+ for (const [key, category] of Object.entries(config.categories ?? {})) for (const name of category?.components ?? []) {
346
+ const block = String(name);
347
+ if (!config.components[block] || groups.has(block)) continue;
348
+ groups.set(block, category?.title ?? key);
349
+ order.push(block);
350
+ }
351
+ for (const name of Object.keys(config.components)) if (!groups.has(name)) order.push(name);
352
+ return order.map((name) => {
353
+ const block = siteConfig?.blocks[name];
354
+ return {
355
+ name,
356
+ label: config.components[name]?.label ?? name,
357
+ ...groups.has(name) && { group: groups.get(name) },
358
+ keywords: (block && blockKeywords(block)) ?? []
359
+ };
360
+ });
361
+ }
362
+ /** A block's name as words: `MediaText` is "media text". */
363
+ function nameWords(name) {
364
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]+/g, " ");
365
+ }
366
+ /** Whether every word typed starts a word of `text`. */
367
+ function startsWords(text, words) {
368
+ const lower = ` ${text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ")}`;
369
+ return words.every((word) => lower.includes(` ${word}`));
370
+ }
371
+ /** The blocks `query` finds, best first: by label, then by name or keyword, then by group. */
372
+ function searchBlocks(query, blocks) {
373
+ const words = query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
374
+ if (words.length === 0) return [...blocks];
375
+ const rank = (block) => {
376
+ if (block.label.toLowerCase().startsWith(query.trim().toLowerCase())) return 0;
377
+ if (startsWords(block.label, words)) return 1;
378
+ if (startsWords(nameWords(block.name), words) || block.keywords.some((keyword) => startsWords(keyword, words))) return 2;
379
+ if (block.group && startsWords(`${block.group} ${block.label}`, words)) return 3;
380
+ };
381
+ return blocks.map((block, index) => ({
382
+ block,
383
+ index,
384
+ rank: rank(block)
385
+ })).filter((found) => found.rank !== void 0).sort((a, b) => a.rank - b.rank || a.index - b.index).map(({ block }) => block);
386
+ }
387
+ /** Puck's `drawer` override: the search box, then Puck's own list or what's found. */
388
+ function BlockSearch({ blocks, item, children }) {
389
+ const t = useStrings();
390
+ const [query, setQuery] = useState("");
391
+ const id = useId();
392
+ const found = query.trim() ? searchBlocks(query, blocks) : void 0;
393
+ return /* @__PURE__ */ jsxs("div", {
394
+ className: "gfa-block-search",
395
+ children: [/* @__PURE__ */ jsx("div", {
396
+ className: "gfa-block-search-box",
397
+ children: /* @__PURE__ */ jsx("input", {
398
+ id,
399
+ type: "search",
400
+ className: "gfa-block-search-input",
401
+ "aria-label": t("editor.blockSearch"),
402
+ placeholder: t("editor.blockSearch"),
403
+ value: query,
404
+ onChange: (event) => setQuery(event.target.value),
405
+ onKeyDown: (event) => {
406
+ if (event.key === "Escape") setQuery("");
407
+ }
408
+ })
409
+ }), found === void 0 ? children : found.length === 0 ? /* @__PURE__ */ jsx("p", {
410
+ className: "gfa-block-search-none",
411
+ role: "status",
412
+ children: t("editor.blockSearchNone")
413
+ }) : /* @__PURE__ */ jsx("section", {
414
+ className: "gfa-block-search-results",
415
+ "aria-label": t("editor.blockSearchFound"),
416
+ children: /* @__PURE__ */ jsx(Drawer, { children: found.map((block) => /* @__PURE__ */ jsx(Drawer.Item, {
417
+ name: block.name,
418
+ label: block.label,
419
+ children: item
420
+ }, block.name)) })
421
+ })]
422
+ });
423
+ }
424
+ //#endregion
301
425
  //#region src/event-field.tsx
302
426
  const EVERY_LABELS = {
303
427
  none: "event.repeat.none",
@@ -907,6 +1031,7 @@ function EntryPreview({ collection, entry, content, templateConfig, layoutConfig
907
1031
  menus: content.menus,
908
1032
  path: entry.path ?? "/",
909
1033
  collections: content.collections,
1034
+ pages: pageLinks(content),
910
1035
  collection,
911
1036
  entry: current
912
1037
  }), [
@@ -999,9 +1124,76 @@ function entryConfig(collection, entry, content, configs) {
999
1124
  * The site's code, run in the page that has loaded it: the frame that runs
1000
1125
  * it for the admin panel, or tests. Never imported by the admin panel's own page.
1001
1126
  */
1127
+ /** The block drawn in each slot's place, which only drawing has: its name can't be one of a site's. */
1128
+ const DRAWN_SLOT_BLOCK = "__goodfellow_drawn_slot__";
1129
+ /** A block that draws nothing but an empty `DRAWN_SLOT_ELEMENT` with its slot's name. */
1130
+ const drawnSlot = {
1131
+ fields: { slot: { type: "text" } },
1132
+ render: ({ slot }) => createElement(DRAWN_SLOT_ELEMENT, { "data-slot": String(slot ?? "") })
1133
+ };
1134
+ /** The block as it's drawn: each of its slots holding only the block that marks its place. */
1135
+ function withSlotPlaces(block, config) {
1136
+ const fields = config?.fields ?? {};
1137
+ const places = Object.entries(fields).filter(([, field]) => field.type === "slot").map(([slot]) => [slot, [{
1138
+ type: DRAWN_SLOT_BLOCK,
1139
+ props: {
1140
+ id: `${String(block.props.id)}:${slot}`,
1141
+ slot
1142
+ }
1143
+ }]]);
1144
+ return {
1145
+ ...block,
1146
+ props: {
1147
+ ...block.props,
1148
+ ...Object.fromEntries(places)
1149
+ }
1150
+ };
1151
+ }
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 }) {
1154
+ const layout = kind === "header" || kind === "footer";
1155
+ const config = layout ? configs.layout : kind === "template" ? configs.template : configs.page;
1156
+ if (!config.components[block.type]) throw new Error(`The site has no block called ${block.type} here.`);
1157
+ const found = allPages(content).find((page) => page.path === path);
1158
+ 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])]
1168
+ }
1169
+ }
1170
+ };
1171
+ const prepared = await preparePage({
1172
+ ...configs,
1173
+ page: config
1174
+ }, content, page);
1175
+ const container = document.createElement("div");
1176
+ const reactRoot = createRoot(container);
1177
+ try {
1178
+ flushSync(() => reactRoot.render(createElement(PageContent, {
1179
+ site: {
1180
+ ...prepared.site,
1181
+ credits: []
1182
+ },
1183
+ pageConfig: prepared.pageConfig,
1184
+ page: prepared.data
1185
+ })));
1186
+ const main = container.querySelector("main");
1187
+ const zone = main?.children.length === 1 ? main.firstElementChild : null;
1188
+ return (zone?.tagName === "DIV" && zone.attributes.length === 0 ? zone : main)?.innerHTML ?? "";
1189
+ } finally {
1190
+ reactRoot.unmount();
1191
+ }
1192
+ }
1002
1193
  /** The site's code run here, in the page that has loaded it: the frame, or tests. */
1003
1194
  function localSiteCode(config) {
1004
1195
  const configs = createPuckConfigs(config);
1196
+ let drawing;
1005
1197
  return {
1006
1198
  async sitePages(content, today) {
1007
1199
  return sitePages(content, today, config.blocks);
@@ -1039,6 +1231,17 @@ function localSiteCode(config) {
1039
1231
  root.unmount();
1040
1232
  }
1041
1233
  return problems;
1234
+ },
1235
+ async drawBlock(content, target) {
1236
+ if (typeof document === "undefined") throw new Error("Blocks are drawn only in a browser.");
1237
+ drawing ??= createPuckConfigs({
1238
+ ...config,
1239
+ blocks: {
1240
+ ...config.blocks,
1241
+ [DRAWN_SLOT_BLOCK]: drawnSlot
1242
+ }
1243
+ });
1244
+ return drawBlock(drawing, content, target);
1042
1245
  }
1043
1246
  };
1044
1247
  }
@@ -1070,11 +1273,11 @@ function useAdminClient() {
1070
1273
  return value;
1071
1274
  }
1072
1275
  /** Media from the admin panel's page, which reads files the live site doesn't have yet from the repository. */
1073
- function frameMediaPreviews(client, siteUrl) {
1276
+ function frameMediaPreviews(client, siteUrl, storage = () => void 0) {
1074
1277
  return createMediaPreviews({ readBytes: async (path) => {
1075
1278
  if (!path.startsWith(`${MEDIA_DIR}/`)) return void 0;
1076
1279
  return await client.call("media.bytes", mediaUrl(path)) ?? void 0;
1077
- } }, siteUrl);
1280
+ } }, siteUrl, storage);
1078
1281
  }
1079
1282
  function refused() {
1080
1283
  throw new Error("The site's frame can't do this.");
@@ -1089,7 +1292,9 @@ function FrameProviders({ config, client, init, site, children }) {
1089
1292
  code: localSiteCode(config)
1090
1293
  }), [config]);
1091
1294
  const described = useMemo(() => adminSite(config), [config]);
1092
- const mediaPreviews = useMemo(() => frameMediaPreviews(client, init.siteUrl), [client, init.siteUrl]);
1295
+ const storage = useRef(site.content.mediaStorage);
1296
+ storage.current = site.content.mediaStorage;
1297
+ const mediaPreviews = useMemo(() => frameMediaPreviews(client, init.siteUrl, () => storage.current), [client, init.siteUrl]);
1093
1298
  useEffect(() => client.channel.handle("media.remember", (file) => {
1094
1299
  if (typeof file?.url === "string" && file.bytes instanceof Uint8Array) mediaPreviews.remember(file.url, file.bytes);
1095
1300
  }), [client, mediaPreviews]);
@@ -1117,6 +1322,7 @@ function FrameProviders({ config, client, init, site, children }) {
1117
1322
  drafts: {
1118
1323
  kept: true,
1119
1324
  names: [],
1325
+ reviews: [],
1120
1326
  open: async () => refused(),
1121
1327
  publishAll: async () => refused(),
1122
1328
  submit: async () => refused(),
@@ -1124,6 +1330,7 @@ function FrameProviders({ config, client, init, site, children }) {
1124
1330
  },
1125
1331
  readFile: async () => refused(),
1126
1332
  listFiles: async () => refused(),
1333
+ readBytes: async () => refused(),
1127
1334
  registries: {},
1128
1335
  outOfReach: false,
1129
1336
  contentOnly: false,
@@ -1221,6 +1428,497 @@ function shownSettings(original, path) {
1221
1428
  };
1222
1429
  }
1223
1430
  //#endregion
1431
+ //#region src/frame/style-field.tsx
1432
+ /**
1433
+ * A block's Style field (`classNameField` in `@goodfellow-cms/react`), which
1434
+ * 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
1437
+ * (`withBlockStyle()` in `@goodfellow-cms/core`), with any other classes kept.
1438
+ */
1439
+ /** A color's value to show and compare: a theme color's, or the editor's own. */
1440
+ function shownColor(value, colors) {
1441
+ if (!value) return void 0;
1442
+ const theme = themeColorOf(value);
1443
+ return theme ? colors[theme] ?? DEFAULT_THEME_COLORS[theme] : value;
1444
+ }
1445
+ /**
1446
+ * A choice from a scale, or a size of the editor's own: the scale's steps,
1447
+ * "As the block has it" and "Your own…", which shows a box for a length.
1448
+ */
1449
+ function ScaleChoice({ label, value, steps, stepLabel, readOnly, onChange }) {
1450
+ const t = useStrings();
1451
+ const id = useId();
1452
+ const custom = value !== void 0 && !steps.includes(value);
1453
+ const [ownChosen, setOwnChosen] = useState(custom);
1454
+ const [typed, setTyped] = useState(custom ? value : "");
1455
+ useEffect(() => {
1456
+ if (value !== void 0 && !steps.includes(value)) {
1457
+ setOwnChosen(true);
1458
+ setTyped(value);
1459
+ }
1460
+ }, [value, steps]);
1461
+ const own = custom || ownChosen;
1462
+ const invalid = own && typed.trim() !== "" && customLength(typed) === void 0;
1463
+ return /* @__PURE__ */ jsxs("div", {
1464
+ className: "gfa-style-choice",
1465
+ children: [
1466
+ /* @__PURE__ */ jsx("label", {
1467
+ className: "gfa-style-label",
1468
+ htmlFor: id,
1469
+ children: label
1470
+ }),
1471
+ /* @__PURE__ */ jsxs("select", {
1472
+ id,
1473
+ className: "gfa-input",
1474
+ disabled: readOnly,
1475
+ value: own ? "custom" : value ?? "",
1476
+ onChange: (event) => {
1477
+ const chosen = event.target.value;
1478
+ if (chosen === "custom") {
1479
+ setOwnChosen(true);
1480
+ const length = customLength(typed);
1481
+ if (length) onChange(length);
1482
+ return;
1483
+ }
1484
+ setOwnChosen(false);
1485
+ onChange(chosen === "" ? void 0 : chosen);
1486
+ },
1487
+ children: [
1488
+ /* @__PURE__ */ jsx("option", {
1489
+ value: "",
1490
+ children: t("style.default")
1491
+ }),
1492
+ steps.map((step) => /* @__PURE__ */ jsx("option", {
1493
+ value: step,
1494
+ children: stepLabel(step)
1495
+ }, step)),
1496
+ /* @__PURE__ */ jsx("option", {
1497
+ value: "custom",
1498
+ children: t("style.custom")
1499
+ })
1500
+ ]
1501
+ }),
1502
+ own && /* @__PURE__ */ jsx("input", {
1503
+ className: "gfa-input",
1504
+ value: typed,
1505
+ readOnly,
1506
+ "aria-label": `${label}: ${t("style.customLength")}`,
1507
+ placeholder: t("style.customLength"),
1508
+ "aria-invalid": invalid,
1509
+ onChange: (event) => {
1510
+ setTyped(event.target.value);
1511
+ const length = customLength(event.target.value);
1512
+ if (length) onChange(length);
1513
+ }
1514
+ }),
1515
+ invalid && /* @__PURE__ */ jsx("p", {
1516
+ className: "gfa-error-text",
1517
+ children: t("style.customLengthInvalid")
1518
+ })
1519
+ ]
1520
+ });
1521
+ }
1522
+ /** Spacing steps as people read them: `4` is 16px. */
1523
+ function spacingLabel(t, step) {
1524
+ return step === "0" ? t("style.none") : `${Number(step) * 4}px`;
1525
+ }
1526
+ /** Space on every side at once, or on each side. */
1527
+ function SidesChoice({ label, sides, readOnly, onChange }) {
1528
+ const t = useStrings();
1529
+ const values = SIDES.map((side) => sides?.[side]);
1530
+ const same = values.every((value) => value === values[0]);
1531
+ const [each, setEach] = useState(!same);
1532
+ useEffect(() => {
1533
+ if (!same) setEach(true);
1534
+ }, [same]);
1535
+ const set = (next) => {
1536
+ const kept = Object.fromEntries(Object.entries(next).filter(([, value]) => value !== void 0));
1537
+ onChange(Object.keys(kept).length > 0 ? kept : void 0);
1538
+ };
1539
+ const stepLabel = (step) => spacingLabel(t, step);
1540
+ return /* @__PURE__ */ jsxs("fieldset", {
1541
+ className: "gfa-style-group",
1542
+ children: [
1543
+ /* @__PURE__ */ jsx("legend", {
1544
+ className: "gfa-style-label",
1545
+ children: label
1546
+ }),
1547
+ each ? /* @__PURE__ */ jsx("div", {
1548
+ className: "gfa-style-sides",
1549
+ children: SIDES.map((side) => /* @__PURE__ */ jsx(ScaleChoice, {
1550
+ label: t(`style.side.${side}`),
1551
+ value: sides?.[side],
1552
+ steps: SPACING_STEPS,
1553
+ stepLabel,
1554
+ readOnly,
1555
+ onChange: (value) => set({
1556
+ ...sides,
1557
+ [side]: value
1558
+ })
1559
+ }, side))
1560
+ }) : /* @__PURE__ */ jsx(ScaleChoice, {
1561
+ label: t("style.allSides"),
1562
+ value: values[0],
1563
+ steps: SPACING_STEPS,
1564
+ stepLabel,
1565
+ readOnly,
1566
+ onChange: (value) => set(Object.fromEntries(SIDES.map((side) => [side, value])))
1567
+ }),
1568
+ /* @__PURE__ */ jsxs("label", {
1569
+ className: "gfa-style-check",
1570
+ children: [/* @__PURE__ */ jsx("input", {
1571
+ type: "checkbox",
1572
+ checked: each,
1573
+ disabled: readOnly || each && !same,
1574
+ onChange: (event) => setEach(event.target.checked)
1575
+ }), t("style.eachSide")]
1576
+ })
1577
+ ]
1578
+ });
1579
+ }
1580
+ /** The theme's colors as swatches, a color of the editor's own, and back to the block's. */
1581
+ function ColorChoice({ label, value, colors, readOnly, onChange }) {
1582
+ const t = useStrings();
1583
+ const own = value !== void 0 && isCustomColor(value);
1584
+ return /* @__PURE__ */ jsxs("fieldset", {
1585
+ className: "gfa-style-group",
1586
+ children: [/* @__PURE__ */ jsx("legend", {
1587
+ className: "gfa-style-label",
1588
+ children: label
1589
+ }), /* @__PURE__ */ jsxs("div", {
1590
+ className: "gfa-style-swatches",
1591
+ children: [
1592
+ /* @__PURE__ */ jsx("button", {
1593
+ type: "button",
1594
+ className: "gfa-style-swatch gfa-style-swatch-none",
1595
+ "aria-pressed": value === void 0,
1596
+ disabled: readOnly,
1597
+ title: t("style.colorDefault"),
1598
+ "aria-label": t("style.colorDefault"),
1599
+ onClick: () => onChange(void 0)
1600
+ }),
1601
+ THEME_COLORS.map((color) => {
1602
+ const slug = colorSlug(color);
1603
+ const name = t(`theme.color.${color}`);
1604
+ return /* @__PURE__ */ jsx("button", {
1605
+ type: "button",
1606
+ className: "gfa-style-swatch",
1607
+ style: { background: colors[color] ?? DEFAULT_THEME_COLORS[color] },
1608
+ "aria-pressed": value === slug,
1609
+ disabled: readOnly,
1610
+ title: name,
1611
+ "aria-label": name,
1612
+ onClick: () => onChange(slug)
1613
+ }, color);
1614
+ }),
1615
+ /* @__PURE__ */ jsx("label", {
1616
+ className: "gfa-style-swatch gfa-style-swatch-own",
1617
+ title: t("style.colorCustom"),
1618
+ children: /* @__PURE__ */ jsx("input", {
1619
+ type: "color",
1620
+ value: own ? value : "#000000",
1621
+ disabled: readOnly,
1622
+ "aria-label": `${label}: ${t("style.colorCustom")}`,
1623
+ onChange: (event) => onChange(event.target.value.toLowerCase())
1624
+ })
1625
+ })
1626
+ ]
1627
+ })]
1628
+ });
1629
+ }
1630
+ /** Other classes, kept as typed while the box has focus, so a class isn't taken into the controls halfway. */
1631
+ function OtherClasses({ other, readOnly, onChange }) {
1632
+ const t = useStrings();
1633
+ const id = useId();
1634
+ const joined = other.join(" ");
1635
+ const [typed, setTyped] = useState(joined);
1636
+ const [focused, setFocused] = useState(false);
1637
+ useEffect(() => {
1638
+ if (!focused) setTyped(joined);
1639
+ }, [joined, focused]);
1640
+ return /* @__PURE__ */ jsxs("div", {
1641
+ className: "gfa-style-choice",
1642
+ children: [
1643
+ /* @__PURE__ */ jsx("label", {
1644
+ className: "gfa-style-label",
1645
+ htmlFor: id,
1646
+ children: t("style.other")
1647
+ }),
1648
+ /* @__PURE__ */ jsx("input", {
1649
+ id,
1650
+ className: "gfa-input gfa-code",
1651
+ value: typed,
1652
+ readOnly,
1653
+ spellCheck: false,
1654
+ onFocus: () => setFocused(true),
1655
+ onBlur: () => setFocused(false),
1656
+ onChange: (event) => {
1657
+ setTyped(event.target.value);
1658
+ onChange(event.target.value.split(/\s+/).filter(Boolean));
1659
+ }
1660
+ }),
1661
+ /* @__PURE__ */ jsx("p", {
1662
+ className: "gfa-hint",
1663
+ children: t("style.otherHint")
1664
+ })
1665
+ ]
1666
+ });
1667
+ }
1668
+ function StyleControls({ value, colors, readOnly, label, ownAlign, onChange }) {
1669
+ const t = useStrings();
1670
+ const { style, other } = readBlockStyle(value);
1671
+ const set = (next) => onChange(withBlockStyle(other, next));
1672
+ const change = (key, next) => {
1673
+ const { [key]: _old, ...rest } = style;
1674
+ set(next === void 0 ? rest : {
1675
+ ...rest,
1676
+ [key]: next
1677
+ });
1678
+ };
1679
+ const text = shownColor(style.color, colors);
1680
+ const background = shownColor(style.background, colors);
1681
+ const ratio = text && background ? contrastRatio(text, background) : void 0;
1682
+ return /* @__PURE__ */ jsxs("div", {
1683
+ className: "gfa-style-field",
1684
+ children: [
1685
+ label && /* @__PURE__ */ jsx("div", {
1686
+ className: "gfa-style-title",
1687
+ children: label
1688
+ }),
1689
+ !ownAlign && /* @__PURE__ */ jsxs("fieldset", {
1690
+ className: "gfa-style-group",
1691
+ children: [/* @__PURE__ */ jsx("legend", {
1692
+ className: "gfa-style-label",
1693
+ children: t("style.align")
1694
+ }), /* @__PURE__ */ jsx("div", {
1695
+ className: "gfa-style-segments",
1696
+ children: [void 0, ...TEXT_ALIGNS].map((align) => /* @__PURE__ */ jsx("button", {
1697
+ type: "button",
1698
+ className: "gfa-style-segment",
1699
+ "aria-pressed": style.align === align,
1700
+ disabled: readOnly,
1701
+ onClick: () => change("align", align),
1702
+ children: t(`style.align.${align ?? "default"}`)
1703
+ }, align ?? "default"))
1704
+ })]
1705
+ }),
1706
+ /* @__PURE__ */ jsx(ScaleChoice, {
1707
+ label: t("style.fontSize"),
1708
+ value: style.fontSize,
1709
+ steps: FONT_SIZES,
1710
+ stepLabel: (step) => t(`style.fontSize.${step}`),
1711
+ readOnly,
1712
+ onChange: (size) => change("fontSize", size)
1713
+ }),
1714
+ /* @__PURE__ */ jsx(ColorChoice, {
1715
+ label: t("style.color"),
1716
+ value: style.color,
1717
+ colors,
1718
+ readOnly,
1719
+ onChange: (color) => change("color", color)
1720
+ }),
1721
+ /* @__PURE__ */ jsx(ColorChoice, {
1722
+ label: t("style.background"),
1723
+ value: style.background,
1724
+ colors,
1725
+ readOnly,
1726
+ onChange: (color) => change("background", color)
1727
+ }),
1728
+ ratio !== void 0 && ratio < MINIMUM_CONTRAST && /* @__PURE__ */ jsx("p", {
1729
+ className: "gfa-hint gfa-style-warning",
1730
+ role: "status",
1731
+ children: t("style.contrast")
1732
+ }),
1733
+ /* @__PURE__ */ jsx(SidesChoice, {
1734
+ label: t("style.padding"),
1735
+ sides: style.padding,
1736
+ readOnly,
1737
+ onChange: (sides) => change("padding", sides)
1738
+ }),
1739
+ /* @__PURE__ */ jsx(SidesChoice, {
1740
+ label: t("style.margin"),
1741
+ sides: style.margin,
1742
+ readOnly,
1743
+ onChange: (sides) => change("margin", sides)
1744
+ }),
1745
+ /* @__PURE__ */ jsx(ScaleChoice, {
1746
+ label: t("style.radius"),
1747
+ value: style.radius,
1748
+ steps: RADII,
1749
+ stepLabel: (step) => t(`style.radius.${step}`),
1750
+ readOnly,
1751
+ onChange: (radius) => change("radius", radius)
1752
+ }),
1753
+ /* @__PURE__ */ jsx(OtherClasses, {
1754
+ other,
1755
+ readOnly,
1756
+ onChange: (next) => onChange(withBlockStyle(next, style))
1757
+ }),
1758
+ /* @__PURE__ */ jsx("button", {
1759
+ type: "button",
1760
+ className: "gfa-button gfa-button-secondary",
1761
+ disabled: readOnly || Object.keys(style).length === 0 && other.length === 0,
1762
+ onClick: () => onChange(""),
1763
+ children: t("style.reset")
1764
+ })
1765
+ ]
1766
+ });
1767
+ }
1768
+ function withStyleField(field, colors, ownAlign) {
1769
+ if (field.type !== "text" || !isStyleField(field)) return field;
1770
+ const render = ({ value, onChange, readOnly }) => /* @__PURE__ */ jsx(StyleControls, {
1771
+ value,
1772
+ colors,
1773
+ readOnly,
1774
+ label: field.label,
1775
+ ownAlign,
1776
+ onChange
1777
+ });
1778
+ return {
1779
+ type: "custom",
1780
+ label: field.label,
1781
+ metadata: field.metadata,
1782
+ render
1783
+ };
1784
+ }
1785
+ function withStyleFieldsIn(fields, colors) {
1786
+ if (!fields) return fields;
1787
+ const ownAlign = "align" in fields;
1788
+ return Object.fromEntries(Object.entries(fields).map(([name, field]) => [name, withStyleField(field, colors, ownAlign)]));
1789
+ }
1790
+ function withStyleComponent(component, colors) {
1791
+ if (!component) return component;
1792
+ const { resolveFields } = component;
1793
+ return {
1794
+ ...component,
1795
+ ...component.fields && { fields: withStyleFieldsIn(component.fields, colors) },
1796
+ ...resolveFields && { resolveFields: async (...args) => withStyleFieldsIn(await resolveFields(...args), colors) }
1797
+ };
1798
+ }
1799
+ /** The config with every block's Style field, and the page's, drawn as a style's controls. */
1800
+ function withStyleFields(config, colors) {
1801
+ const components = Object.fromEntries(Object.entries(config.components).map(([name, component]) => [name, withStyleComponent(component, colors)]));
1802
+ return {
1803
+ ...config,
1804
+ components,
1805
+ ...config.root && { root: withStyleComponent(config.root, colors) }
1806
+ };
1807
+ }
1808
+ //#endregion
1809
+ //#region src/frame/table-field.tsx
1810
+ /**
1811
+ * The grid of cells a table's field is edited in (`tableField()` in
1812
+ * `@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.
1814
+ */
1815
+ const MAX_ROWS = 100;
1816
+ const MAX_COLUMNS = 12;
1817
+ function TableCellsField({ value, onChange, readOnly, label }) {
1818
+ const t = useStrings();
1819
+ const rows = tableCells(value);
1820
+ const width = rows[0]?.length ?? 1;
1821
+ const set = (row, column, text) => onChange(rows.map((cells, r) => r === row ? cells.map((cell, c) => c === column ? text : cell) : cells));
1822
+ return /* @__PURE__ */ jsxs("div", {
1823
+ className: "gfa-table-field",
1824
+ children: [
1825
+ label && /* @__PURE__ */ jsx("div", {
1826
+ className: "gfa-table-field-label",
1827
+ children: label
1828
+ }),
1829
+ /* @__PURE__ */ jsxs("div", {
1830
+ className: "gfa-table-field-grid",
1831
+ style: { gridTemplateColumns: `repeat(${width}, minmax(6rem, 1fr)) auto` },
1832
+ children: [rows.map((cells, row) => /* @__PURE__ */ jsxs("div", {
1833
+ className: "gfa-table-field-row",
1834
+ children: [cells.map((cell, column) => /* @__PURE__ */ jsx("input", {
1835
+ className: "gfa-input gfa-table-field-cell",
1836
+ value: cell,
1837
+ readOnly,
1838
+ "aria-label": t("table.cell", {
1839
+ row: String(row + 1),
1840
+ column: String(column + 1)
1841
+ }),
1842
+ onChange: (event) => set(row, column, event.target.value)
1843
+ }, column)), /* @__PURE__ */ jsx("button", {
1844
+ type: "button",
1845
+ className: "gfa-table-field-remove",
1846
+ disabled: readOnly || rows.length <= 1,
1847
+ title: t("table.removeRow"),
1848
+ "aria-label": t("table.removeRowNumber", { row: String(row + 1) }),
1849
+ onClick: () => onChange(rows.filter((_, r) => r !== row)),
1850
+ children: "×"
1851
+ })]
1852
+ }, row)), /* @__PURE__ */ jsx("div", {
1853
+ className: "gfa-table-field-row",
1854
+ children: Array.from({ length: width }, (_, column) => /* @__PURE__ */ jsx("button", {
1855
+ type: "button",
1856
+ className: "gfa-table-field-remove",
1857
+ disabled: readOnly || width <= 1,
1858
+ title: t("table.removeColumn"),
1859
+ "aria-label": t("table.removeColumnNumber", { column: String(column + 1) }),
1860
+ onClick: () => onChange(rows.map((cells) => cells.filter((_, c) => c !== column))),
1861
+ children: "×"
1862
+ }, column))
1863
+ })]
1864
+ }),
1865
+ /* @__PURE__ */ jsxs("div", {
1866
+ className: "gfa-table-field-actions",
1867
+ children: [/* @__PURE__ */ jsx("button", {
1868
+ type: "button",
1869
+ className: "gfa-button gfa-button-secondary",
1870
+ disabled: readOnly || rows.length >= MAX_ROWS,
1871
+ onClick: () => onChange([...rows, Array(width).fill("")]),
1872
+ children: t("table.addRow")
1873
+ }), /* @__PURE__ */ jsx("button", {
1874
+ type: "button",
1875
+ className: "gfa-button gfa-button-secondary",
1876
+ disabled: readOnly || width >= MAX_COLUMNS,
1877
+ onClick: () => onChange(rows.map((cells) => [...cells, ""])),
1878
+ children: t("table.addColumn")
1879
+ })]
1880
+ })
1881
+ ]
1882
+ });
1883
+ }
1884
+ function withTableField(field) {
1885
+ if (field.type === "custom" && isTableField(field)) {
1886
+ const render = ({ value, onChange, readOnly, field: own }) => /* @__PURE__ */ jsx(TableCellsField, {
1887
+ value,
1888
+ onChange,
1889
+ readOnly,
1890
+ label: own.label
1891
+ });
1892
+ return {
1893
+ ...field,
1894
+ render
1895
+ };
1896
+ }
1897
+ if (field.type === "array") return {
1898
+ ...field,
1899
+ arrayFields: withTableFieldsIn(field.arrayFields)
1900
+ };
1901
+ if (field.type === "object") return {
1902
+ ...field,
1903
+ objectFields: withTableFieldsIn(field.objectFields)
1904
+ };
1905
+ return field;
1906
+ }
1907
+ function withTableFieldsIn(fields) {
1908
+ return Object.fromEntries(Object.entries(fields ?? {}).map(([name, field]) => [name, withTableField(field)]));
1909
+ }
1910
+ /** The config with every table's field edited as a grid of cells. */
1911
+ function withTableFields(config) {
1912
+ const components = Object.fromEntries(Object.entries(config.components).map(([name, component]) => [name, component.fields ? {
1913
+ ...component,
1914
+ fields: withTableFieldsIn(component.fields)
1915
+ } : component]));
1916
+ return {
1917
+ ...config,
1918
+ components
1919
+ };
1920
+ }
1921
+ //#endregion
1224
1922
  //#region src/frame/editor.tsx
1225
1923
  /**
1226
1924
  * Puck in the site's frame, for one page, header, footer, template or item.
@@ -1412,6 +2110,14 @@ const ENTRY_UI = {
1412
2110
  };
1413
2111
  const entryPlugins = [legacySideBarPlugin()];
1414
2112
  /** Puck, set up for one page, header, footer, template or item: site context, previews and the bridge. */
2113
+ /** Puck's permissions for something shown without editing: nothing can be changed, added, moved or removed. */
2114
+ const READ_ONLY = {
2115
+ drag: false,
2116
+ duplicate: false,
2117
+ delete: false,
2118
+ edit: false,
2119
+ insert: false
2120
+ };
1415
2121
  function FrameEditor({ open, onOpened }) {
1416
2122
  const client = useAdminClient();
1417
2123
  const { config: siteConfig, pageConfig, layoutConfig, templateConfig } = useFrameCode();
@@ -1489,18 +2195,25 @@ function FrameEditor({ open, onOpened }) {
1489
2195
  }
1490
2196
  };
1491
2197
  }, [client]);
2198
+ const callPlugin = useCallback((plugin, method, ...args) => client.call("plugin.call", {
2199
+ plugin,
2200
+ method,
2201
+ args
2202
+ }), [client]);
1492
2203
  const site = useMemo(() => ({
1493
2204
  settings: content.settings,
1494
2205
  menus: content.menus,
1495
2206
  path,
1496
2207
  collections: content.collections,
2208
+ pages: pageLinks(content),
1497
2209
  collection,
1498
2210
  ...view && { view },
1499
2211
  ...languages && {
1500
2212
  languages,
1501
2213
  home: languageHome(loaded, language)
1502
2214
  },
1503
- adminLink
2215
+ adminLink,
2216
+ callPlugin
1504
2217
  }), [
1505
2218
  content,
1506
2219
  loaded,
@@ -1509,7 +2222,8 @@ function FrameEditor({ open, onOpened }) {
1509
2222
  collection,
1510
2223
  view,
1511
2224
  languages,
1512
- adminLink
2225
+ adminLink,
2226
+ callPlugin
1513
2227
  ]);
1514
2228
  const entry = kind === "entry" && collection && open.entry ? findEntry(loaded, collection.id, open.entry) : void 0;
1515
2229
  const config = useMemo(() => {
@@ -1520,7 +2234,7 @@ function FrameEditor({ open, onOpened }) {
1520
2234
  layoutConfig
1521
2235
  });
1522
2236
  }
1523
- return canvasConfig(kind, kind === "page" ? pageConfig : kind === "template" ? templateConfig : layoutConfig, layoutConfig, content.header.data, content.footer.data, site);
2237
+ return withTableFields(canvasConfig(kind, kind === "page" ? pageConfig : kind === "template" ? templateConfig : layoutConfig, layoutConfig, content.header.data, content.footer.data, site));
1524
2238
  }, [
1525
2239
  kind,
1526
2240
  collection,
@@ -1532,6 +2246,11 @@ function FrameEditor({ open, onOpened }) {
1532
2246
  site
1533
2247
  ]);
1534
2248
  const described = useMemo(() => describeConfig(config), [config]);
2249
+ const puckConfig = useMemo(() => kind === "entry" ? config : withStyleFields(config, content.settings.theme.colors), [
2250
+ kind,
2251
+ config,
2252
+ content.settings.theme.colors
2253
+ ]);
1535
2254
  const told = useRef(void 0);
1536
2255
  useEffect(() => {
1537
2256
  if (told.current === described) return;
@@ -1549,25 +2268,42 @@ function FrameEditor({ open, onOpened }) {
1549
2268
  ...kind === "entry" ? ENTRY_UI : {},
1550
2269
  ...open.ui
1551
2270
  }), [kind, open.ui]);
2271
+ const searchable = useMemo(() => searchableBlocks(config, siteConfig), [config, siteConfig]);
2272
+ const drawerItem = useMemo(() => {
2273
+ const icons = /* @__PURE__ */ new Map();
2274
+ for (const [name, block] of Object.entries(siteConfig.blocks)) {
2275
+ const icon = blockIcon(block);
2276
+ if (icon) icons.set(name, icon);
2277
+ }
2278
+ return drawerItemWithIcon(icons);
2279
+ }, [siteConfig]);
1552
2280
  const overrides = useMemo(() => ({
1553
2281
  header: () => /* @__PURE__ */ jsx(Bridge, {
1554
2282
  channel: client.channel,
1555
2283
  settings
1556
2284
  }),
2285
+ drawer: ({ children }) => /* @__PURE__ */ jsx(BlockSearch, {
2286
+ blocks: searchable,
2287
+ item: drawerItem,
2288
+ children
2289
+ }),
2290
+ drawerItem,
1557
2291
  fieldTypes: { text: TextFieldWithMedia },
1558
2292
  ...kind === "page" && path && { fields: ({ children, itemSelector }) => /* @__PURE__ */ jsxs(Fragment$1, { children: [children, !itemSelector && /* @__PURE__ */ jsx(SharePreview, { path })] }) }
1559
2293
  }), [
1560
2294
  client,
1561
2295
  settings,
1562
2296
  kind,
1563
- path
2297
+ path,
2298
+ searchable,
2299
+ drawerItem
1564
2300
  ]);
1565
2301
  return /* @__PURE__ */ jsx("div", {
1566
2302
  className: `gfa-editor-puck gfa-editor-for-${kind}`,
1567
2303
  children: /* @__PURE__ */ jsx(SiteProvider, {
1568
2304
  value: site,
1569
2305
  children: /* @__PURE__ */ jsx(Puck, {
1570
- config,
2306
+ config: puckConfig,
1571
2307
  data: initialData,
1572
2308
  metadata: siteMetadata(site),
1573
2309
  headerTitle: title,
@@ -1575,6 +2311,7 @@ function FrameEditor({ open, onOpened }) {
1575
2311
  iframe: { enabled: false },
1576
2312
  ui,
1577
2313
  ...kind === "entry" && { plugins: entryPlugins },
2314
+ ...open.readOnly && { permissions: READ_ONLY },
1578
2315
  overrides,
1579
2316
  onChange: updateView
1580
2317
  })
@@ -1676,7 +2413,8 @@ function CodeService({ channel }) {
1676
2413
  const stops = [
1677
2414
  channel.handle("code.sitePages", ({ content, today }) => code.sitePages(content, today)),
1678
2415
  channel.handle("code.pageView", async ({ page, content, today }) => await code.pageView(page, content, today) ?? null),
1679
- channel.handle("code.renderedProblems", ({ content, targets, today }) => code.renderedProblems(content, targets, today))
2416
+ channel.handle("code.renderedProblems", ({ content, targets, today }) => code.renderedProblems(content, targets, today)),
2417
+ channel.handle("code.drawBlock", ({ content, target }) => code.drawBlock(content, target))
1680
2418
  ];
1681
2419
  return () => {
1682
2420
  for (const stop of stops) stop();