@goodfellow-cms/admin 0.6.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.
@@ -1,5 +1,5 @@
1
1
  import { n as CONNECT_MESSAGE, r as createChannel } from "./plugin-protocol-DkvBDqsF.js";
2
- import { APP_API_HEADER, COLOR_SCHEME_ATTRIBUTE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, MAX_UPLOAD_BYTES, MEDIA_DIR, MENUS_FILE, PluginTaskError, REDIRECTS_FILE, SHADCN_NAMESPACE, SHADCN_REGISTRY, SITE_FILE, ScheduledBuildsError, SignInError, TASK_INPUT_LIMIT, addressPatternFor, adminOrigin, allPages, checkEntrySlug, checkPageAddress, checkSite, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, customCssFileChange, deleteCollectionChanges, describedConfig, duplicatePageData, entryFileChange, entryFileChange as entryFileChange$1, entryLanguage, entryPathIn, entryTitle, escapeStyleText, extensionOf, fileName, goodfellowRegistryUrl, googleFontsUrl, isDemoStore, isStorableAddress, keptDrafts, layoutFileChange, loadSiteContent, markdownToHtml, mediaFileChange, mediaKind, mediaKind as mediaKind$1, mediaStorageFileChange, memoryKeeper, menusFileChange, moveEntryChanges, movePageChanges, newCollectionSettings, newDraftName, newPageData, newTaskRequestId, openTaskAnswer, outsideHost, pageFileChange, pageTitle as pageTitle$1, pluginFrameUrl, pluginMayList, pluginMayUse, publishProblems, redirectsFileChange, relinkSite, relinkedChanges, scheduleOf, scheduleProblem, scheduleState, siteLanguages, siteSettingsFileChange, slugify, storedCss, storedData, storedEntryFields, storedMediaUrl, taskKeys, themeToCss, todayIn, trimChars, uniqueName, upcomingChanges, updateSettingsFileChange, uploadName, wallClock, withStoredFiles, withoutCredits, writeChanges } from "@goodfellow-cms/core";
2
+ import { APP_API_HEADER, COLOR_SCHEME_ATTRIBUTE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, MAX_UPLOAD_BYTES, MEDIA_DIR, MENUS_FILE, PluginTaskError, REDIRECTS_FILE, SHADCN_NAMESPACE, SHADCN_REGISTRY, SITE_FILE, ScheduledBuildsError, SignInError, TASK_INPUT_LIMIT, addressPatternFor, adminOrigin, allPages, checkEntrySlug, checkPageAddress, checkSite, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, customCssFileChange, deleteCollectionChanges, describedConfig, duplicatePageData, entryFileChange, entryFileChange as entryFileChange$1, entryLanguage, entryPathIn, entryTitle, escapeStyleText, extensionOf, fileName, goodfellowRegistryUrl, googleFontsUrl, isDemoStore, isStorableAddress, keptDrafts, layoutFileChange, limitsFor, loadSiteContent, markdownToHtml, mayChangeEntry, mayChangeLayout, mayChangeMedia, mayChangeMenus, mayChangePage, mediaFileChange, mediaKind, mediaKind as mediaKind$1, mediaStorageFileChange, memoryKeeper, menusFileChange, moveEntryChanges, movePageChanges, newCollectionSettings, newDraftName, newPageData, newTaskRequestId, openTaskAnswer, outsideHost, outsideLimits, pageFileChange, pageTitle as pageTitle$1, pluginFrameUrl, pluginMayList, pluginMayUse, publishProblems, redirectsFileChange, relinkSite, relinkedChanges, reviewersFor, scheduleOf, scheduleProblem, scheduleState, siteLanguages, siteSettingsFileChange, slugify, storedCss, storedData, storedEntryFields, storedMediaUrl, taskKeys, themeToCss, todayIn, trimChars, uniqueName, upcomingChanges, updateSettingsFileChange, uploadName, wallClock, withStoredFiles, withoutCredits, writeChanges } from "@goodfellow-cms/core";
3
3
  import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
4
  import TurndownService from "turndown";
5
5
  import { gfm } from "turndown-plugin-gfm";
@@ -493,7 +493,15 @@ const defaultStrings = {
493
493
  "editor.failed": "The editor stopped working. Reload the page to start it again.",
494
494
  "editor.frame": "Editing {title}",
495
495
  "editor.choose": "Editor",
496
+ "editor.blockSearch": "Search blocks",
497
+ "editor.blockSearchNone": "No blocks match. Try another word, such as \"picture\" or \"columns\".",
498
+ "editor.blockSearchFound": "Blocks found",
496
499
  "editor.standard": "Standard editor",
500
+ "editor.plugin.switchLoses": "Some of what you changed in {editor} can't be kept in the other editor, so switching now leaves behind everything you changed since you opened {editor}. Switch anyway?\n\n{problems}",
501
+ "editor.plugin.pageSettings": "The page's settings",
502
+ "editor.plugin.problem": "{block}: {message}",
503
+ "editor.plugin.problemField": "{block}, {field}: {message}",
504
+ "editor.plugin.loading": "Opening {editor}…",
497
505
  "editor.plugin.failed": "{editor} didn't start. Choose the standard editor to keep editing.",
498
506
  "editor.plugin.invalid": "{editor} gave back something this site can't publish, so nothing was published. Try again, or choose the standard editor.",
499
507
  "publish.publishing": "Publishing…",
@@ -522,6 +530,9 @@ const defaultStrings = {
522
530
  "draft.leave": "Back to the live site",
523
531
  "draft.waiting": "A draft started {date} is waiting to be published.",
524
532
  "draft.waitingMany": "{count} drafts are waiting to be published.",
533
+ "draft.reviewWaiting": "A draft is waiting for review: {title}.",
534
+ "draft.reviewWaitingMany": "{count} drafts are waiting for review.",
535
+ "draft.inReview": "Submitted for review.",
525
536
  "draft.choose": "Draft to open",
526
537
  "draft.started": "Started {date}",
527
538
  "draft.open": "Open",
@@ -541,6 +552,22 @@ const defaultStrings = {
541
552
  "checks.alsoWarnings": "These could be better too:",
542
553
  "checks.publishAnyway": "Publish anyway",
543
554
  "checks.goBack": "Go back",
555
+ "limits.title": "Some of this is outside what you can change",
556
+ "limits.intro": "An owner has limited what you can change on this site, and these changes are outside it:",
557
+ "limits.review": "Submit them for review instead: the site's reviewers are asked to look, and can publish them for you.",
558
+ "limits.noReview": "Ask an owner to make these changes for you, or to let you make them on Site Settings' Editors screen.",
559
+ "limits.goBack": "Go back",
560
+ "limits.submitting": "Submitting for review…",
561
+ "limits.submitted": "Submitted for review. The live site stays as it is until a reviewer publishes it.",
562
+ "limits.submitError": "The changes couldn't be submitted for review. Check your connection and try again.",
563
+ "limits.page": "The page {name}",
564
+ "limits.entry": "{name}, in {collection}",
565
+ "limits.media": "The media library",
566
+ "limits.layout": "The header and footer",
567
+ "limits.menus": "The menus",
568
+ "limits.other": "The site's settings, or other things only owners change",
569
+ "limits.readOnly": "You can look at this, but an owner has limited what you can change, and it's outside it.",
570
+ "limits.mediaReadOnly": "An owner has limited what you can change, and the media library is outside it. You can still use its files on pages you can change.",
544
571
  "checks.missing-page": "{place} links to {target}, which isn't a page on the site.",
545
572
  "checks.missing-media": "{place} uses {target}, which isn't in the media library.",
546
573
  "checks.no-title": "{place} has no title, so browser tabs and search results show only the site's name.",
@@ -572,6 +599,7 @@ const defaultStrings = {
572
599
  "pages.empty": "This site has no pages yet.",
573
600
  "pages.column.title": "Title",
574
601
  "pages.column.address": "Address",
602
+ "pages.look": "Look",
575
603
  "pages.edit": "Edit",
576
604
  "pages.move": "Change address",
577
605
  "pages.duplicate": "Duplicate",
@@ -633,7 +661,7 @@ const defaultStrings = {
633
661
  "deletePage.submit": "Delete page",
634
662
  "deletePage.message": "Delete page \"{title}\"",
635
663
  "address.invalid": "Use lowercase letters, numbers and hyphens, with / between parts, such as /about-us.",
636
- "address.reserved": "This address is used by the admin panel. Choose another.",
664
+ "address.reserved": "This address can't be used: the admin panel uses it, or it ends in /index, which is the page above it. Choose another.",
637
665
  "address.taken": "Another page already uses this address.",
638
666
  "field.required": "This can't be empty.",
639
667
  "editPage.message": "Update page \"{title}\"",
@@ -711,7 +739,7 @@ const defaultStrings = {
711
739
  "newEntry.addressHint": "The last part of the page's address, as in {example}.",
712
740
  "newEntry.message": "Add {entry} \"{title}\"",
713
741
  "entryName.invalid": "Use lowercase letters, numbers and hyphens only, such as easter-vigil.",
714
- "entryName.reserved": "This address is used by the admin panel. Choose another.",
742
+ "entryName.reserved": "This address can't be used: the admin panel uses it, or it ends in /index, which is the page above it. Choose another.",
715
743
  "entryName.taken": "Something else on the site already uses this address.",
716
744
  "moveEntry.message": "Move {entry} \"{title}\" from {from} to {to}",
717
745
  "deleteEntry.body": "{title} will be removed from {name}.",
@@ -904,6 +932,51 @@ const defaultStrings = {
904
932
  "table.removeColumn": "Remove this column",
905
933
  "table.removeColumnNumber": "Remove column {column}",
906
934
  "table.cell": "Row {row}, column {column}",
935
+ "style.align": "Text alignment",
936
+ "style.align.default": "As the block has it",
937
+ "style.align.left": "Left",
938
+ "style.align.center": "Centered",
939
+ "style.align.right": "Right",
940
+ "style.align.justify": "Justified",
941
+ "style.fontSize": "Text size",
942
+ "style.fontSize.xs": "Extra small",
943
+ "style.fontSize.sm": "Small",
944
+ "style.fontSize.base": "Normal",
945
+ "style.fontSize.lg": "Large",
946
+ "style.fontSize.xl": "Extra large",
947
+ "style.fontSize.2xl": "Very large",
948
+ "style.fontSize.3xl": "Huge",
949
+ "style.fontSize.4xl": "Very huge",
950
+ "style.fontSize.5xl": "Giant",
951
+ "style.fontSize.6xl": "Enormous",
952
+ "style.color": "Text color",
953
+ "style.background": "Background color",
954
+ "style.colorDefault": "As the block has it",
955
+ "style.colorCustom": "Choose a color",
956
+ "style.contrast": "This text may be hard to read on this background. Try colors further apart.",
957
+ "style.padding": "Space inside",
958
+ "style.margin": "Space around",
959
+ "style.eachSide": "Set each side",
960
+ "style.side.top": "Top",
961
+ "style.side.right": "Right",
962
+ "style.side.bottom": "Bottom",
963
+ "style.side.left": "Left",
964
+ "style.allSides": "All sides",
965
+ "style.none": "None",
966
+ "style.radius": "Rounded corners",
967
+ "style.radius.none": "Square",
968
+ "style.radius.sm": "Slightly rounded",
969
+ "style.radius.md": "Rounded",
970
+ "style.radius.lg": "The theme's rounding",
971
+ "style.radius.xl": "Very rounded",
972
+ "style.radius.full": "Round",
973
+ "style.default": "As the block has it",
974
+ "style.custom": "Your own…",
975
+ "style.customLength": "Your own size, such as 12px or 1.5rem",
976
+ "style.customLengthInvalid": "Write a number with px, rem, em or %, such as 12px.",
977
+ "style.other": "Other CSS classes",
978
+ "style.otherHint": "Tailwind classes, or your own from Custom CSS, separated by spaces.",
979
+ "style.reset": "Back to the block's own look",
907
980
  "media.credit": "Credit: {credit}",
908
981
  "media.stored": "Kept by {plugin}",
909
982
  "media.error.storageSize": "{name} is too large: {plugin} takes files up to {size}.",
@@ -1014,6 +1087,7 @@ const defaultStrings = {
1014
1087
  "settings.previewEmpty": "Add a home page to see a preview here.",
1015
1088
  "domain.intro": "Connect a domain you own, such as example.org, so the site is at that address.",
1016
1089
  "domain.current": "The site is at {address} now.",
1090
+ "domain.copy": "This admin panel is a copy of the site's, such as a staging site, at {address}. That address is set where the copy is built, so the domain this screen connects is the main site's.",
1017
1091
  "domain.connected": "Connected to {domain}.",
1018
1092
  "domain.signIn": "Editors sign in by going to {host} and coming back here. Add {address} to the redirect URIs of the application they sign in with on {host}, or signing in will stop working once the site moves.",
1019
1093
  "domain.local": "Domains are connected in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
@@ -1540,7 +1614,7 @@ const defaultStrings = {
1540
1614
  "updates.error.save": "The change couldn't be saved. Try again in a moment.",
1541
1615
  "updates.error.other": "Something went wrong. Try again in a moment.",
1542
1616
  "editors.intro": "The people who can edit this site.",
1543
- "editors.roles": "Editors can change and publish everything in the admin panel. Owners can also invite and remove editors, and connect a domain.",
1617
+ "editors.roles": "Editors can change and publish everything in the admin panel, unless their limits below say otherwise. Owners can also invite and remove editors, limit what they can change, and connect a domain.",
1544
1618
  "editors.local": "Editors are invited in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
1545
1619
  "editors.demo": "Editors can't be invited in a demo.",
1546
1620
  "editors.loading": "Loading editors…",
@@ -1576,6 +1650,33 @@ const defaultStrings = {
1576
1650
  "editors.error.invalid": "That isn't a username or email address that {host} accepts.",
1577
1651
  "editors.error.already": "They already edit the site, or have been invited.",
1578
1652
  "editors.error.other": "Something went wrong. Try again in a moment.",
1653
+ "permissions.title": "Limits",
1654
+ "permissions.intro": "Limit what someone can change: some pages, some collections, the media library, the header and footer, or the menus. Anyone not listed here can change everything their role allows. Changes outside someone's limits go to a reviewer, who publishes them or not.",
1655
+ "permissions.where": "The admin panel keeps people to their limits, so nobody changes the wrong page by mistake. People who can publish to the site on {host} could still change other files there, so only a site with its own server holds everyone to them.",
1656
+ "permissions.whereServer": "The site's server holds everyone to their limits, whatever they try.",
1657
+ "permissions.host": "the site's host",
1658
+ "permissions.person": "Their username",
1659
+ "permissions.personHint": "The name they sign in with, as the list above shows it.",
1660
+ "permissions.add": "Limit what they can change",
1661
+ "permissions.change": "Change",
1662
+ "permissions.remove": "Remove limits",
1663
+ "permissions.nothing": "Nothing yet",
1664
+ "permissions.allPages": "Every page",
1665
+ "permissions.editTitle": "What {name} can change",
1666
+ "permissions.editIntro": "Tick what they can change. They can look at everything else, and submit changes to it for review.",
1667
+ "permissions.pages": "Pages",
1668
+ "permissions.pagesHint": "Each with the pages under it, and their translations.",
1669
+ "permissions.collections": "Collections' items",
1670
+ "permissions.more": "Also",
1671
+ "permissions.languages": "Languages",
1672
+ "permissions.everyLanguage": "Every language",
1673
+ "permissions.save": "Publish limits",
1674
+ "permissions.message": "Change what {name} can change",
1675
+ "permissions.removeMessage": "Let {name} change everything",
1676
+ "permissions.reviewers": "Who reviews changes outside someone's limits",
1677
+ "permissions.reviewersHint": "Usernames, separated by commas. Leave it empty for every owner.",
1678
+ "permissions.saveReviewers": "Publish reviewers",
1679
+ "permissions.reviewersMessage": "Change who reviews changes outside editors' limits",
1579
1680
  "general.siteTitle": "Site name",
1580
1681
  "general.description": "Description",
1581
1682
  "general.descriptionHint": "Shown by search engines when a page doesn't have its own description.",
@@ -2235,6 +2336,119 @@ function SiteCodeFrame({ onClient }) {
2235
2336
  });
2236
2337
  }
2237
2338
  //#endregion
2339
+ //#region src/limits-dialog.tsx
2340
+ /** What a change outside someone's limits touches, in words. Files that are none of those are named once, together. */
2341
+ function describeLimitProblems(t, content, problems) {
2342
+ const described = /* @__PURE__ */ new Set();
2343
+ const pages = allPages(content);
2344
+ for (const problem of problems) if (problem.kind === "page") {
2345
+ const title = (pages.find((found) => found.path === problem.address)?.content.data.root.props)?.title;
2346
+ described.add(t("limits.page", { name: typeof title === "string" && title ? title : problem.address }));
2347
+ } else if (problem.kind === "entry") {
2348
+ const collection = content.collections.find((found) => found.id === problem.collection);
2349
+ const title = (collection?.entries.find((found) => found.slug === problem.slug))?.content.fields.title;
2350
+ described.add(t("limits.entry", {
2351
+ name: typeof title === "string" && title ? title : problem.slug,
2352
+ collection: collection?.settings.name ?? problem.collection
2353
+ }));
2354
+ } else described.add(t(`limits.${problem.kind}`));
2355
+ return [...described];
2356
+ }
2357
+ /**
2358
+ * Changes outside what someone may change: listed, with what they can do
2359
+ * instead, submitting them for review where the site can take one.
2360
+ */
2361
+ function LimitsDialog({ problems, content, onSubmit, onClose }) {
2362
+ const t = useStrings();
2363
+ const [state, setState] = useState({ type: "asking" });
2364
+ const submit = async () => {
2365
+ if (!onSubmit) return;
2366
+ setState({ type: "submitting" });
2367
+ try {
2368
+ setState({
2369
+ type: "submitted",
2370
+ ...await onSubmit()
2371
+ });
2372
+ } catch (error) {
2373
+ setState({
2374
+ type: "error",
2375
+ error
2376
+ });
2377
+ }
2378
+ };
2379
+ return /* @__PURE__ */ jsxs(Dialog, {
2380
+ title: t("limits.title"),
2381
+ onClose,
2382
+ children: [
2383
+ /* @__PURE__ */ jsx("p", { children: t("limits.intro") }),
2384
+ /* @__PURE__ */ jsx("ul", {
2385
+ className: "gfa-check-list",
2386
+ children: describeLimitProblems(t, content, problems).map((line) => /* @__PURE__ */ jsx("li", { children: line }, line))
2387
+ }),
2388
+ state.type === "submitted" ? /* @__PURE__ */ jsxs("p", {
2389
+ role: "status",
2390
+ children: [
2391
+ t("limits.submitted"),
2392
+ " ",
2393
+ state.url && /* @__PURE__ */ jsx("a", {
2394
+ href: state.url,
2395
+ target: "_blank",
2396
+ rel: "noreferrer",
2397
+ children: t("draft.seeReview")
2398
+ })
2399
+ ]
2400
+ }) : /* @__PURE__ */ jsx("p", { children: t(onSubmit ? "limits.review" : "limits.noReview") }),
2401
+ state.type === "error" && /* @__PURE__ */ jsx(ErrorMessage, {
2402
+ message: t("limits.submitError"),
2403
+ error: state.error
2404
+ }),
2405
+ /* @__PURE__ */ jsxs("div", {
2406
+ className: "gfa-dialog-actions",
2407
+ children: [/* @__PURE__ */ jsx(Button, {
2408
+ onClick: onClose,
2409
+ children: t(state.type === "submitted" ? "action.close" : "limits.goBack")
2410
+ }), onSubmit && state.type !== "submitted" && /* @__PURE__ */ jsx(Button, {
2411
+ variant: "primary",
2412
+ disabled: state.type === "submitting",
2413
+ onClick: () => void submit(),
2414
+ children: t(state.type === "submitting" ? "limits.submitting" : "draft.submit")
2415
+ })]
2416
+ })
2417
+ ]
2418
+ });
2419
+ }
2420
+ //#endregion
2421
+ //#region src/limits.ts
2422
+ /**
2423
+ * What the signed-in person may change, for screens that show the rest
2424
+ * read-only. Publishing checks every change again (`outsideLimits()`), so
2425
+ * these only spare people changes they couldn't publish.
2426
+ */
2427
+ /** Whether someone with `limits` may change what's open. Collections' templates are their settings, which limits never cover. */
2428
+ function mayChange(content, limits, what) {
2429
+ if (!limits) return true;
2430
+ switch (what.kind) {
2431
+ case "page": {
2432
+ const page = content.pages.find((found) => found.path === what.path);
2433
+ return mayChangePage(content, limits, page ?? { path: what.path });
2434
+ }
2435
+ case "header":
2436
+ case "footer": return mayChangeLayout(content, limits, what.language);
2437
+ case "template": return false;
2438
+ case "entry": {
2439
+ const entry = content.collections.find((found) => found.id === what.collection)?.entries.find((found) => found.slug === what.entry);
2440
+ return entry ? mayChangeEntry(content, limits, entry) : (limits.collections ?? []).includes(what.collection);
2441
+ }
2442
+ case "media": return mayChangeMedia(limits);
2443
+ case "menus": return mayChangeMenus(content, limits, what.language);
2444
+ }
2445
+ }
2446
+ /** Whether the signed-in person may change what's open, once the site has loaded. */
2447
+ function useMayChange(what) {
2448
+ const { state, limits } = useAdmin();
2449
+ return state.status !== "ready" || mayChange(state.content, limits, what);
2450
+ }
2451
+ //#endregion
2238
2452
  //#region src/media-previews.ts
2239
2453
  const TYPES = {
2240
2454
  jpg: "image/jpeg",
@@ -3632,6 +3846,7 @@ function MediaScreen() {
3632
3846
  const [copied, setCopied] = useState(null);
3633
3847
  const [moved, setMoved] = useState();
3634
3848
  const searchId = useId();
3849
+ const mayChange = useMayChange({ kind: "media" });
3635
3850
  useEffect(() => {
3636
3851
  const stop = (event) => {
3637
3852
  if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
@@ -3650,7 +3865,7 @@ function MediaScreen() {
3650
3865
  children: [
3651
3866
  /* @__PURE__ */ jsxs("div", {
3652
3867
  className: "gfa-screen-header",
3653
- children: [/* @__PURE__ */ jsx("h1", { children: t("media.title") }), /* @__PURE__ */ jsx(UploadButton, {
3868
+ children: [/* @__PURE__ */ jsx("h1", { children: t("media.title") }), mayChange && /* @__PURE__ */ jsx(UploadButton, {
3654
3869
  label: t("media.upload"),
3655
3870
  disabled: status.type === "uploading",
3656
3871
  onFiles: (files) => void run(files)
@@ -3660,6 +3875,11 @@ function MediaScreen() {
3660
3875
  className: "gfa-hint",
3661
3876
  children: t("media.intro")
3662
3877
  }),
3878
+ !mayChange && /* @__PURE__ */ jsx("p", {
3879
+ className: "gfa-notice",
3880
+ role: "note",
3881
+ children: t("limits.mediaReadOnly")
3882
+ }),
3663
3883
  cloudflareVideos && /* @__PURE__ */ jsxs("p", {
3664
3884
  className: "gfa-notice",
3665
3885
  role: "note",
@@ -3674,7 +3894,7 @@ function MediaScreen() {
3674
3894
  })
3675
3895
  ]
3676
3896
  }),
3677
- /* @__PURE__ */ jsx(StorageSection, { onDone: setMoved }),
3897
+ mayChange && /* @__PURE__ */ jsx(StorageSection, { onDone: setMoved }),
3678
3898
  moved && /* @__PURE__ */ jsx("p", {
3679
3899
  className: "gfa-notice gfa-notice-success",
3680
3900
  role: "status",
@@ -3696,7 +3916,7 @@ function MediaScreen() {
3696
3916
  })]
3697
3917
  }),
3698
3918
  /* @__PURE__ */ jsx(DropZone, {
3699
- onFiles: (files) => void run(files),
3919
+ onFiles: (files) => void (mayChange && run(files)),
3700
3920
  children: media.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("media.empty") }) : urls.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("media.noMatches") }) : /* @__PURE__ */ jsx("ul", {
3701
3921
  className: "gfa-media-grid",
3702
3922
  children: urls.map((url) => /* @__PURE__ */ jsxs("li", {
@@ -3715,25 +3935,21 @@ function MediaScreen() {
3715
3935
  }),
3716
3936
  /* @__PURE__ */ jsxs("div", {
3717
3937
  className: "gfa-media-actions",
3718
- children: [
3719
- /* @__PURE__ */ jsx(Button, {
3720
- variant: "ghost",
3721
- onClick: () => void navigator.clipboard?.writeText(url).then(() => setCopied(url), () => setCopied(null)),
3722
- children: copied === url ? t("media.copied") : t("media.copy")
3723
- }),
3724
- /* @__PURE__ */ jsx(UploadButton, {
3725
- label: t("media.replace"),
3726
- variant: "secondary",
3727
- multiple: false,
3728
- disabled: status.type === "uploading",
3729
- onFiles: (files) => void run(files.slice(0, 1), mediaPath(url))
3730
- }),
3731
- /* @__PURE__ */ jsx(Button, {
3732
- variant: "ghost",
3733
- onClick: () => setDeleting(url),
3734
- children: t("media.delete")
3735
- })
3736
- ]
3938
+ children: [/* @__PURE__ */ jsx(Button, {
3939
+ variant: "ghost",
3940
+ onClick: () => void navigator.clipboard?.writeText(url).then(() => setCopied(url), () => setCopied(null)),
3941
+ children: copied === url ? t("media.copied") : t("media.copy")
3942
+ }), mayChange && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(UploadButton, {
3943
+ label: t("media.replace"),
3944
+ variant: "secondary",
3945
+ multiple: false,
3946
+ disabled: status.type === "uploading",
3947
+ onFiles: (files) => void run(files.slice(0, 1), mediaPath(url))
3948
+ }), /* @__PURE__ */ jsx(Button, {
3949
+ variant: "ghost",
3950
+ onClick: () => setDeleting(url),
3951
+ children: t("media.delete")
3952
+ })] })]
3737
3953
  })
3738
3954
  ]
3739
3955
  }, url))
@@ -3967,7 +4183,7 @@ function pluginSecretsHelp(place, plugin, t) {
3967
4183
  //#endregion
3968
4184
  //#region src/registry-version.ts
3969
4185
  /** The release of Goodfellow's block registry this admin panel was built with, so a site gets blocks that match it. */
3970
- const REGISTRY_VERSION = "0.6.0";
4186
+ const REGISTRY_VERSION = "0.7.0";
3971
4187
  //#endregion
3972
4188
  //#region src/schedule.tsx
3973
4189
  /** The computer's own time zone, which a site without one takes when something is first scheduled. */
@@ -4318,13 +4534,15 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4318
4534
  const [draftPaths, setDraftPaths] = useState([]);
4319
4535
  const content = draft?.store ?? store;
4320
4536
  const [checking, setChecking] = useState();
4537
+ const [limiting, setLimiting] = useState();
4538
+ const [draftReviews, setDraftReviews] = useState([]);
4321
4539
  const blocks = useMemo(() => describedConfig(site.description, "template").components, [site.description]);
4322
4540
  const storage = useRef(void 0);
4323
4541
  storage.current = state.status === "ready" ? state.content.mediaStorage : void 0;
4324
4542
  const mediaPreviews = useMemo(() => createMediaPreviews(content, siteUrl, () => storage.current), [content, siteUrl]);
4325
4543
  const outOfReach = registry !== void 0 || atAdminAddress(site.admin) || site.broker !== void 0;
4326
4544
  const registries = useMemo(() => ({
4327
- [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.6.0"),
4545
+ [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.7.0"),
4328
4546
  ...outOfReach && {
4329
4547
  [SHADCN_NAMESPACE]: SHADCN_REGISTRY,
4330
4548
  ...site.registries
@@ -4422,9 +4640,14 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4422
4640
  reload
4423
4641
  ]);
4424
4642
  const refreshDrafts = useCallback(async (current) => {
4425
- const [names, paths] = await Promise.all([siteDrafts.list().catch(() => []), current ? current.paths().catch(() => []) : []]);
4643
+ const [names, paths, reviews] = await Promise.all([
4644
+ siteDrafts.list().catch(() => []),
4645
+ current ? current.paths().catch(() => []) : [],
4646
+ siteDrafts.reviews().catch(() => [])
4647
+ ]);
4426
4648
  setDraftNames(names);
4427
4649
  setDraftPaths(paths);
4650
+ setDraftReviews(reviews);
4428
4651
  }, [siteDrafts]);
4429
4652
  useEffect(() => {
4430
4653
  if (draftFound) refreshDrafts(draft);
@@ -4444,12 +4667,64 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4444
4667
  setChecking(void 0);
4445
4668
  return go;
4446
4669
  }, []);
4670
+ const limits = useMemo(() => state.status === "ready" ? limitsFor(state.content.permissions, account?.user.login) : void 0, [state, account]);
4671
+ /** Who's asked to review: those the permissions name, else every owner, never the one asking. */
4672
+ const reviewers = useCallback(async () => {
4673
+ const owners = await editors?.list().then((list) => list.editors.flatMap((editor) => editor.role === "owner" && editor.login ? [editor.login] : [])).catch(() => []);
4674
+ const self = account?.user.login.toLowerCase();
4675
+ const permissions = state.status === "ready" ? state.content.permissions : void 0;
4676
+ return reviewersFor(permissions, owners ?? []).filter((login) => login.toLowerCase() !== self);
4677
+ }, [
4678
+ editors,
4679
+ account,
4680
+ state
4681
+ ]);
4682
+ /** Shows what's outside someone's limits, and lets them submit it for review where the site can take one. */
4683
+ const askLimits = useCallback((problems, shown, submit) => new Promise((done) => setLimiting({
4684
+ problems,
4685
+ content: shown,
4686
+ ...submit && { submit },
4687
+ done
4688
+ })), []);
4447
4689
  const publish = useCallback(async (changes, message, options) => {
4448
4690
  if (state.status !== "ready") return {
4449
4691
  ok: false,
4450
4692
  reason: "error",
4451
4693
  error: /* @__PURE__ */ new Error("The site isn't loaded.")
4452
4694
  };
4695
+ const outside = draft || options?.draft ? [] : outsideLimits(state.content, limits, changes);
4696
+ if (outside.length > 0) {
4697
+ const from = state.revision;
4698
+ const description = [
4699
+ t("draft.reviewDescription"),
4700
+ "",
4701
+ ...changes.map((change) => `- \`${change.path}\``)
4702
+ ].join("\n");
4703
+ const submit = backendDrafts ? async () => {
4704
+ const review = await siteDrafts.create(newDraftName(), { from });
4705
+ await writeChanges(review.store, changes, {
4706
+ message,
4707
+ expectedRevision: await review.store.revision()
4708
+ });
4709
+ const { url } = await review.submitForReview({
4710
+ title: message,
4711
+ description,
4712
+ reviewers: await reviewers()
4713
+ });
4714
+ refreshDrafts(void 0);
4715
+ return { url };
4716
+ } : account?.review ? () => account.review?.(changes, {
4717
+ title: message,
4718
+ description,
4719
+ from
4720
+ }) ?? Promise.resolve({}) : void 0;
4721
+ await askLimits(outside, state.content, submit);
4722
+ return {
4723
+ ok: false,
4724
+ reason: "checks",
4725
+ error: void 0
4726
+ };
4727
+ }
4453
4728
  const checks = options?.checks === false ? void 0 : await checkPublish(content, state, changes, {
4454
4729
  blocks,
4455
4730
  code
@@ -4504,7 +4779,13 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4504
4779
  confirmChecks,
4505
4780
  refreshDrafts,
4506
4781
  loadFrom,
4507
- onSignInError
4782
+ onSignInError,
4783
+ limits,
4784
+ backendDrafts,
4785
+ account,
4786
+ askLimits,
4787
+ reviewers,
4788
+ t
4508
4789
  ]);
4509
4790
  const openDraft = useCallback(async (name) => {
4510
4791
  if (name === draft?.name) return true;
@@ -4548,6 +4829,29 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4548
4829
  reason: "error",
4549
4830
  error: live.status === "error" && live.error
4550
4831
  };
4832
+ const outside = outsideLimits(live.content, limitsFor(live.content.permissions, account?.user.login), changes);
4833
+ if (outside.length > 0) {
4834
+ const current = draft;
4835
+ const submit = backendDrafts ? async () => {
4836
+ const paths = changes.map((change) => change.path);
4837
+ return current.submitForReview({
4838
+ title: t("draft.reviewTitle", { name: current.name }),
4839
+ description: [
4840
+ t("draft.reviewDescription"),
4841
+ "",
4842
+ ...paths.map((path) => `- \`${path}\``)
4843
+ ].join("\n"),
4844
+ reviewers: await reviewers()
4845
+ });
4846
+ } : void 0;
4847
+ await askLimits(outside, live.content, submit);
4848
+ refreshDrafts(current);
4849
+ return {
4850
+ ok: false,
4851
+ reason: "checks",
4852
+ error: void 0
4853
+ };
4854
+ }
4551
4855
  if (!await confirmChecks(await checkPublish(store, live, changes, {
4552
4856
  blocks,
4553
4857
  code
@@ -4584,7 +4888,12 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4584
4888
  key,
4585
4889
  t,
4586
4890
  confirmChecks,
4587
- onSignInError
4891
+ onSignInError,
4892
+ account,
4893
+ backendDrafts,
4894
+ askLimits,
4895
+ reviewers,
4896
+ refreshDrafts
4588
4897
  ]);
4589
4898
  const submitDraft = useCallback(async () => {
4590
4899
  if (!draft) return {
@@ -4599,8 +4908,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4599
4908
  t("draft.reviewDescription"),
4600
4909
  "",
4601
4910
  ...paths.map((path) => `- \`${path}\``)
4602
- ].join("\n")
4911
+ ].join("\n"),
4912
+ reviewers: await reviewers()
4603
4913
  });
4914
+ refreshDrafts(draft);
4604
4915
  return {
4605
4916
  ok: true,
4606
4917
  url
@@ -4611,7 +4922,12 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4611
4922
  error
4612
4923
  };
4613
4924
  }
4614
- }, [draft, t]);
4925
+ }, [
4926
+ draft,
4927
+ t,
4928
+ reviewers,
4929
+ refreshDrafts
4930
+ ]);
4615
4931
  const discardDraft = useCallback(async () => {
4616
4932
  if (!draft) return { ok: true };
4617
4933
  try {
@@ -4630,6 +4946,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4630
4946
  const drafts = useMemo(() => ({
4631
4947
  kept: backendDrafts === void 0,
4632
4948
  names: draftNames,
4949
+ reviews: draftReviews,
4633
4950
  ...draft && { current: {
4634
4951
  name: draft.name,
4635
4952
  paths: draftPaths
@@ -4641,6 +4958,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4641
4958
  }), [
4642
4959
  backendDrafts,
4643
4960
  draftNames,
4961
+ draftReviews,
4644
4962
  draft,
4645
4963
  draftPaths,
4646
4964
  openDraft,
@@ -4692,6 +5010,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4692
5010
  const pluginInstalls = demo || contentOnly ? void 0 : account?.review ? "review" : atAdminAddress(site.admin) || site.broker !== void 0 ? "publish" : void 0;
4693
5011
  const value = useMemo(() => ({
4694
5012
  site,
5013
+ ...limits && { limits },
4695
5014
  blocks,
4696
5015
  code,
4697
5016
  preview,
@@ -4724,6 +5043,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4724
5043
  tasks
4725
5044
  }), [
4726
5045
  site,
5046
+ limits,
4727
5047
  blocks,
4728
5048
  code,
4729
5049
  preview,
@@ -4766,16 +5086,28 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4766
5086
  value,
4767
5087
  children: /* @__PURE__ */ jsxs(StringsProvider, {
4768
5088
  strings,
4769
- children: [state.status === "ready" ? /* @__PURE__ */ jsx(SiteFrames, {
4770
- frames,
4771
- state,
4772
- onCodeClient,
4773
- children
4774
- }) : children, checking && /* @__PURE__ */ jsx(ChecksDialog, {
4775
- checks: checking.checks,
4776
- content: checking.content,
4777
- onDecide: checking.decide
4778
- })]
5089
+ children: [
5090
+ state.status === "ready" ? /* @__PURE__ */ jsx(SiteFrames, {
5091
+ frames,
5092
+ state,
5093
+ onCodeClient,
5094
+ children
5095
+ }) : children,
5096
+ checking && /* @__PURE__ */ jsx(ChecksDialog, {
5097
+ checks: checking.checks,
5098
+ content: checking.content,
5099
+ onDecide: checking.decide
5100
+ }),
5101
+ limiting && /* @__PURE__ */ jsx(LimitsDialog, {
5102
+ problems: limiting.problems,
5103
+ content: limiting.content,
5104
+ ...limiting.submit && { onSubmit: limiting.submit },
5105
+ onClose: () => {
5106
+ setLimiting(void 0);
5107
+ limiting.done();
5108
+ }
5109
+ })
5110
+ ]
4779
5111
  })
4780
5112
  });
4781
5113
  }
@@ -5079,4 +5411,4 @@ function formValues(collection, entry) {
5079
5411
  return values;
5080
5412
  }
5081
5413
  //#endregion
5082
- export { useDebounced as $, useRoute as A, redirectsFileChange as At, FrameServicesProvider as B, AppLink as C, menusFileChange as Ct, hasUnsavedChanges as D, newPageData as Dt, entryEditorHref as E, newCollectionSettings as Et, PluginScreenFrame as F, storedEntryFields as Ft, READY_MESSAGE as G, useFrame as H, usePluginFrame as I, uniqueName as It, Dialog as J, draftStarted as K, usePluginHost as L, updateSettingsFileChange as Lt, prepareUpload as M, slugify as Mt, useOutsideHost as N, storedCss as Nt, navigate as O, pageFileChange as Ot, PublishFailure as P, storedData as Pt, TextField as Q, createMediaPreviews as R, editedMarkdown as Rt, MediaScreen as S, mediaFileChange as St, collectionHref as T, movePageChanges as Tt, useFrameServices as U, reloadFrames as V, useFrameUpdates as W, Field as X, ErrorMessage as Y, PublishedNotice as Z, scheduleSettingsChanges as _, customCssFileChange as _t, AdminContext as a, findPage as at, MediaChooser as b, entryFileChange$1 as bt, useCanChangeSettings as c, mediaUrl as ct, useApplyThemeChoice as d, addressPatternFor as dt, StringsProvider as et, useDarkMode as f, checkEntrySlug as ft, ScheduleStatus as g, collectionSettingsChanges as gt, ScheduleFields as h, collectionFileChange$1 as ht, usePreviewStyles as i, describeContrast as it, useUnsavedChanges as j, siteSettingsFileChange as jt, pageEditorHref as k, pageTitle$1 as kt, useSiteContent as l, siteImages as lt, ComingUp as m, codeFileChanges as mt, formValues as n, useStringTable as nt, AdminProvider as o, allMedia as ot, useThemeChoice as p, checkPageAddress as pt, Button as q, PreviewFrame as r, useStrings as rt, useAdmin as s, fileName as st, SCHEDULE_PROP as t, defaultStrings as tt, setThemeChoice as u, uploadName as ut, REGISTRY_VERSION as v, deleteCollectionChanges as vt, SETTINGS_SECTIONS as w, moveEntryChanges as wt, MediaField as x, layoutFileChange as xt, MediaChoiceContext as y, duplicatePageData as yt, showMediaInPreview as z, formattedHtml as zt };
5414
+ export { PublishedNotice as $, useRoute as A, pageFileChange as At, mayChange as B, editedMarkdown as Bt, AppLink as C, layoutFileChange as Ct, hasUnsavedChanges as D, movePageChanges as Dt, entryEditorHref as E, moveEntryChanges as Et, PluginScreenFrame as F, storedCss as Ft, useFrameServices as G, FrameServicesProvider as H, usePluginFrame as I, storedData as It, draftStarted as J, useFrameUpdates as K, usePluginHost as L, storedEntryFields as Lt, prepareUpload as M, redirectsFileChange as Mt, useOutsideHost as N, siteSettingsFileChange as Nt, navigate as O, newCollectionSettings as Ot, PublishFailure as P, slugify as Pt, Field as Q, createMediaPreviews as R, uniqueName as Rt, MediaScreen as S, entryFileChange$1 as St, collectionHref as T, menusFileChange as Tt, reloadFrames as U, useMayChange as V, formattedHtml as Vt, useFrame as W, Dialog as X, Button as Y, ErrorMessage as Z, scheduleSettingsChanges as _, collectionFileChange$1 as _t, AdminContext as a, useStrings as at, MediaChooser as b, deleteCollectionChanges as bt, useCanChangeSettings as c, allMedia as ct, useApplyThemeChoice as d, siteImages as dt, TextField as et, useDarkMode as f, uploadName as ft, ScheduleStatus as g, codeFileChanges as gt, ScheduleFields as h, checkPageAddress as ht, usePreviewStyles as i, useStringTable as it, useUnsavedChanges as j, pageTitle$1 as jt, pageEditorHref as k, newPageData as kt, useSiteContent as l, fileName as lt, ComingUp as m, checkEntrySlug as mt, formValues as n, StringsProvider as nt, AdminProvider as o, describeContrast as ot, useThemeChoice as p, addressPatternFor as pt, READY_MESSAGE as q, PreviewFrame as r, defaultStrings as rt, useAdmin as s, findPage as st, SCHEDULE_PROP as t, useDebounced as tt, setThemeChoice as u, mediaUrl as ut, REGISTRY_VERSION as v, collectionSettingsChanges as vt, SETTINGS_SECTIONS as w, mediaFileChange as wt, MediaField as x, duplicatePageData as xt, MediaChoiceContext as y, customCssFileChange as yt, showMediaInPreview as z, updateSettingsFileChange as zt };