@goodfellow-cms/admin 0.3.0 → 0.4.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/index.d.ts CHANGED
@@ -58,6 +58,13 @@ export declare const defaultStrings: {
58
58
  readonly "signIn.install": "Install Goodfellow on {host}";
59
59
  readonly "signIn.error.expired": "You were signed out because your sign-in expired. Sign in again to keep editing.";
60
60
  readonly "signIn.error.other": "Couldn't reach {host}. Check your connection and try again.";
61
+ readonly "signIn.app.intro": "Sign in with your account on this site to edit it.";
62
+ readonly "signIn.app.button": "Sign in";
63
+ readonly "signIn.app.expired": "You've been signed out. Sign in again to keep editing.";
64
+ readonly "signIn.app.notAllowed": "You're signed in, but your account isn't allowed to edit this site. Ask one of its owners to give you access.";
65
+ readonly "signIn.app.error": "Couldn't reach the site. Check your connection and try again.";
66
+ readonly "owners.only": "Only the site's owners can change this.";
67
+ readonly "owners.onlyHint": "Ask one of them if something here needs changing.";
61
68
  readonly "account.signedInAs": "Signed in as {name}";
62
69
  readonly "account.signOut": "Sign out";
63
70
  readonly "deploy.building": "Updating the live site…";
@@ -305,6 +312,8 @@ export declare const defaultStrings: {
305
312
  readonly "demo.startOver": "Start over";
306
313
  readonly "demo.startOverTitle": "Start over?";
307
314
  readonly "demo.startOverBody": "Everything you've changed in this demo, including uploads, will be undone.";
315
+ readonly "blocks.contentOnly": "This site's blocks are added by its developer.";
316
+ readonly "blocks.contentOnlyHint": "Its code is kept apart from its content, so adding or removing a block is done where the code is, and shows up here once the site has been rebuilt.";
308
317
  readonly "blocks.demo": "In this demo you can see which blocks there are, but not add or remove them: a block is added to a real site by updating its code.";
309
318
  readonly "markdown.formatted": "Formatted";
310
319
  readonly "markdown.source": "Markdown";
@@ -415,6 +424,8 @@ export declare const defaultStrings: {
415
424
  readonly "ai.provider.manual": "Copy and paste into a chat app (free)";
416
425
  readonly "ai.provider.custom": "Another service (OpenAI-compatible)";
417
426
  readonly "ai.noKey": "(free, no key needed)";
427
+ readonly "ai.siteKey": "(provided by this site)";
428
+ readonly "ai.siteKeyHint": "This site provides {service}, so you don't need a key of your own.";
418
429
  readonly "ai.baseUrl": "Address of the service's API";
419
430
  readonly "ai.baseUrlHint": "Such as https://example.org/v1.";
420
431
  readonly "ai.key": "API key";
@@ -526,6 +537,11 @@ export declare const defaultStrings: {
526
537
  readonly "settings.tab.editors": "Editors";
527
538
  readonly "settings.tab.updates": "Updates";
528
539
  readonly "updates.intro": "Goodfellow, the software the site runs on, gets fixes and new releases. Fixes install on their own each night, and only once the site has been checked to still build with them.";
540
+ readonly "updates.contentOnly": "This site's code is kept apart from its content, so Goodfellow's updates are installed where the code is, by the site's own setup or its developer.";
541
+ readonly "updates.code": "This site's code is kept apart from its content, at {place}, where Goodfellow's updates are installed.";
542
+ readonly "updates.code.link": "See it on {host}";
543
+ readonly "updates.noAccess": "Your sign-in can't see the site's code, at {place}, where its updates are installed. Ask the site's developer to give you access to it. On GitHub, an access token needs to reach it too, or with Sign in with GitHub, Goodfellow's app needs to be installed there.";
544
+ readonly "updates.back.skip": "Don't install Goodfellow {version} again";
529
545
  readonly "updates.local": "Updates are shown in the published site's admin panel, where you sign in with GitHub or GitLab.";
530
546
  readonly "updates.demo": "Updates aren't installed in a demo.";
531
547
  readonly "updates.loading": "Checking for updates…";
@@ -762,6 +778,12 @@ interface AdminProps {
762
778
  store?: ContentStore;
763
779
  /** The git host the site is stored on. Defaults to the config's `backend`. */
764
780
  host?: GitHost;
781
+ /**
782
+ * The address of a full-stack site's API for the admin panel, such as
783
+ * `/api/goodfellow`. Editors then sign in with the site's own sign-in, and its
784
+ * server reads and publishes for them, so they never sign in to the git host.
785
+ */
786
+ server?: string;
765
787
  preview: PreviewOptions;
766
788
  /** The live site's address, for "View" links. Defaults to `/`. */
767
789
  siteUrl?: string;
@@ -774,7 +796,7 @@ interface AdminProps {
774
796
  registry?: string;
775
797
  }
776
798
  /** The admin panel. Render it on its own page, such as `/admin`. */
777
- export declare function Admin({ config, store, host, preview, siteUrl, strings, registry }: AdminProps): import("react").JSX.Element;
799
+ export declare function Admin({ config, store, host, server, preview, siteUrl, strings, registry }: AdminProps): import("react").JSX.Element;
778
800
  /** Renders the admin panel into an element. */
779
801
  export declare function mountAdmin(element: Element, props: AdminProps): void;
780
802
  //#endregion
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CODE_THEMES, COLLECTIONS_DIR, COLOR_SCHEME_ATTRIBUTE, ConflictError, ContentError, DEFAULT_CODE_THEMES, DEFAULT_DARK_THEME_COLORS, DEFAULT_THEME_COLORS, DEMO_CONTENT_PATH, DomainError, EditorsError, FIELD_TYPES, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, HeadCodeError, INSTALLED_RECORD_FILE, MEDIA_DIR, NPM_REGISTRY, REDIRECT_LINK_PLACEHOLDER, REDIRECT_MESSAGE, RegistryError, SignInError, THEME_COLORS, TITLE_FIELD, UPDATES_FILE, UpdatesError, VERSION_PACKAGE, WEEKDAYS, addressPatternFor, addressPatternProblem, allPages, availableBlocks, availableUpdates, blockUses, canFormatMarkdown, checkEntrySlug, checkPageAddress, checkRecords, cleanEventValue, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, collectionFileSchema, compareReleases, credentialStorage, customCssFileChange, datePart, deleteCollectionChanges, demoContentStore, demoStore, domainZone, entryAddress, entryFileChange, entryFileChange as entryFileChange$1, entryTitle, escapeStyleText, eventValueProblem, findEntry, findSitePage, formTarget, formatDate, formatOccurrence, goodfellowRegistryUrl, googleFontsUrl, isAddressSegment, isAllDay, isDemoStore, isEmptyValue, isEventLike, isReservedPagePath, layoutFileChange, loadSiteContent, markdownToHtml, mediaFileChange, menusFileChange, menusFileSchema, moveEntryChanges, movePageChanges, newCollectionSettings, newPageData, normalizeDomain, pageFileChange, pageTitle, pageView, parseDemoContent, parseHeadCode, parseInstalledRecord, parseUpdateSettings, planBlockChanges, recordsInPlace, redirectFrom, redirectProblems, redirectsFileChange, registrarName, releaseVersions, serializeContent, shownOccurrence, sitePages, siteSettingsFileChange, siteSettingsSchema, siteVersion, slugify, storedCss, storedData, storedEntryFields, storedRedirects, themeToCss, todayIn, trimChars, uniqueName, updateSettingsFileChange, wallClock, weekdayName, weekdayOf, withoutCredits, writeChanges } from "@goodfellow-cms/core";
1
+ import { APP_API_HEADER, CODE_THEMES, COLLECTIONS_DIR, COLOR_SCHEME_ATTRIBUTE, ConflictError, ContentError, DEFAULT_CODE_THEMES, DEFAULT_DARK_THEME_COLORS, DEFAULT_THEME_COLORS, DEMO_CONTENT_PATH, DomainError, EditorsError, FIELD_TYPES, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, HeadCodeError, INSTALLED_RECORD_FILE, MEDIA_DIR, NPM_REGISTRY, REDIRECT_LINK_PLACEHOLDER, REDIRECT_MESSAGE, RegistryError, SignInError, THEME_COLORS, TITLE_FIELD, UPDATES_FILE, UpdatesError, VERSION_PACKAGE, WEEKDAYS, addressPatternFor, addressPatternProblem, allPages, availableBlocks, availableUpdates, blockUses, canFormatMarkdown, checkEntrySlug, checkPageAddress, checkRecords, cleanEventValue, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, collectionFileSchema, compareReleases, credentialStorage, customCssFileChange, datePart, decodeBase64, deleteCollectionChanges, demoContentStore, demoStore, domainZone, encodeBase64Bytes, entryAddress, entryFileChange, entryFileChange as entryFileChange$1, entryTitle, escapeStyleText, eventValueProblem, findEntry, findSitePage, formTarget, formatDate, formatOccurrence, goodfellowRegistryUrl, googleFontsUrl, isAddressSegment, isAllDay, isDemoStore, isEmptyValue, isEventLike, isReservedPagePath, layoutFileChange, loadSiteContent, markdownToHtml, mediaFileChange, menusFileChange, menusFileSchema, moveEntryChanges, movePageChanges, newCollectionSettings, newPageData, normalizeDomain, pageFileChange, pageTitle, pageView, parseDemoContent, parseHeadCode, parseInstalledRecord, parseUpdateSettings, planBlockChanges, recordsInPlace, redirectFrom, redirectProblems, redirectsFileChange, registrarName, releaseVersions, serializeContent, shownOccurrence, sitePages, siteSettingsFileChange, siteSettingsSchema, siteVersion, slugify, storedCss, storedData, storedEntryFields, storedRedirects, themeToCss, todayIn, trimChars, uniqueName, updateSettingsFileChange, wallClock, weekdayName, weekdayOf, withoutCredits, writeChanges } from "@goodfellow-cms/core";
2
2
  import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { PageBody, SiteProvider, applyEntry, createPuckConfig, createPuckConfigs, cx, mediaFieldKind, preparePage, siteMetadata } from "@goodfellow-cms/react";
@@ -385,7 +385,7 @@ function AdminProvider({ config, store, account, preview, siteUrl, registry, onS
385
385
  const templateConfig = useMemo(() => createPuckConfig(config, "template"), [config]);
386
386
  const mediaPreviews = useMemo(() => createMediaPreviews(store, siteUrl), [store, siteUrl]);
387
387
  const registries = useMemo(() => ({
388
- [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.3.0"),
388
+ [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.4.0"),
389
389
  ...config.registries
390
390
  }), [registry, config.registries]);
391
391
  const favicon = state.status === "ready" ? state.content.settings.favicon : void 0;
@@ -398,6 +398,7 @@ function AdminProvider({ config, store, account, preview, siteUrl, registry, onS
398
398
  const changedPaths = useMemo(() => isGitBackend(store) ? (from, to) => store.changedPaths(from, to) : void 0, [store]);
399
399
  const editors = isGitBackend(store) ? store.editors : void 0;
400
400
  const updates = isGitBackend(store) ? store.updates : void 0;
401
+ const contentOnly = isGitBackend(store) && store.contentOnly === true;
401
402
  const backendOwner = isGitBackend(store) ? store.ownerAccess : void 0;
402
403
  const [ownerActive, setOwnerActive] = useState(backendOwner?.active ?? false);
403
404
  const ownerAccess = useMemo(() => backendOwner && {
@@ -507,7 +508,8 @@ function AdminProvider({ config, store, account, preview, siteUrl, registry, onS
507
508
  changedPaths,
508
509
  editors,
509
510
  ownerAccess,
510
- updates
511
+ updates,
512
+ contentOnly
511
513
  }), [
512
514
  config,
513
515
  pageConfig,
@@ -530,13 +532,18 @@ function AdminProvider({ config, store, account, preview, siteUrl, registry, onS
530
532
  changedPaths,
531
533
  editors,
532
534
  ownerAccess,
533
- updates
535
+ updates,
536
+ contentOnly
534
537
  ]);
535
538
  return /* @__PURE__ */ jsx(AdminContext.Provider, {
536
539
  value,
537
540
  children
538
541
  });
539
542
  }
543
+ /** Whether the signed-in person may change Site Settings, blocks, and collections' settings and templates. */
544
+ function useCanChangeSettings() {
545
+ return useAdmin().account?.role !== "editor";
546
+ }
540
547
  function useAdmin() {
541
548
  const value = useContext(AdminContext);
542
549
  if (!value) throw new Error("useAdmin() must be used inside <Admin>.");
@@ -642,75 +649,133 @@ function useStockPhotoChoice() {
642
649
  }, [])];
643
650
  }
644
651
  //#endregion
645
- //#region src/router.tsx
646
- function parseHash(hash) {
647
- const [path = "", query = ""] = hash.replace(/^#\/?/, "").split("?");
648
- return {
649
- segments: path.split("/").filter(Boolean),
650
- params: new URLSearchParams(query)
651
- };
652
- }
653
- function subscribe$1(onChange) {
654
- window.addEventListener("hashchange", onChange);
655
- return () => window.removeEventListener("hashchange", onChange);
656
- }
657
- function useRoute() {
658
- return parseHash(useSyncExternalStore(subscribe$1, () => window.location.hash, () => ""));
659
- }
660
- const guards = /* @__PURE__ */ new Set();
661
- /** Whether any screen has unpublished changes. */
662
- function hasUnsavedChanges() {
663
- return [...guards].some((isDirty) => isDirty());
664
- }
665
- /**
666
- * Registers a screen's unpublished changes, so leaving it (inside the admin
667
- * panel or by closing the tab) asks for confirmation first.
668
- */
669
- function useUnsavedChanges(isDirty) {
670
- useEffect(() => {
671
- if (!isDirty) return;
672
- const guard = () => true;
673
- guards.add(guard);
674
- const onBeforeUnload = (event) => event.preventDefault();
675
- window.addEventListener("beforeunload", onBeforeUnload);
676
- return () => {
677
- guards.delete(guard);
678
- window.removeEventListener("beforeunload", onBeforeUnload);
679
- };
680
- }, [isDirty]);
681
- }
682
- /** Goes to another screen, asking first if there are unpublished changes. Returns whether it navigated. */
683
- function navigate(to, confirmLeave) {
684
- if (hasUnsavedChanges() && !confirmLeave()) return false;
685
- guards.clear();
686
- window.location.hash = to;
687
- return true;
688
- }
689
- /** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
690
- const SETTINGS_SECTIONS = {
691
- contact: "contact",
692
- forms: "forms"
652
+ //#region src/app-server.ts
653
+ /** Thrown when the site's server refuses something, such as an editor changing settings only owners may change. */
654
+ var AppServerError = class extends Error {
655
+ status;
656
+ code;
657
+ name = "AppServerError";
658
+ constructor(status, code) {
659
+ super(code === "owners-only" ? "Only the site's owners can change this." : code === "git" || code === "git-sign-in" ? "The site's server couldn't reach where the site is stored." : `The site's server answered ${status} (${code}).`);
660
+ this.status = status;
661
+ this.code = code;
662
+ }
693
663
  };
694
- function pageEditorHref(path) {
695
- return `#/pages/edit?path=${encodeURIComponent(path)}`;
696
- }
697
- /** An in-app link that asks before leaving a screen with unpublished changes. */
698
- function Link({ href, confirmLeave, ...props }) {
699
- return /* @__PURE__ */ jsx("a", {
700
- ...props,
701
- href,
702
- onClick: (event) => {
703
- if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;
704
- event.preventDefault();
705
- navigate(href, confirmLeave);
664
+ /** A full-stack site's API for the admin panel, at `api`, such as `/api/goodfellow`. */
665
+ function appServer(api) {
666
+ const base = api.replace(/\/+$/, "");
667
+ async function request(endpoint, { query, body, missingIsFine = false } = {}) {
668
+ const search = query ? `?${new URLSearchParams(query)}` : "";
669
+ const response = await fetch(`${base}/${endpoint}/${search}`, {
670
+ method: body === void 0 ? "GET" : "POST",
671
+ headers: {
672
+ [APP_API_HEADER]: "1",
673
+ ...body !== void 0 && { "content-type": "application/json" }
674
+ },
675
+ ...body !== void 0 && { body: JSON.stringify(body) },
676
+ credentials: "same-origin",
677
+ cache: "no-store"
678
+ });
679
+ if (response.ok || response.status === 404 && missingIsFine) return response;
680
+ if (response.status === 409) throw new ConflictError();
681
+ const answer = await response.json().catch(() => ({}));
682
+ if (response.status === 401) throw new SignInError("invalid", "Signing in to the site has ended.");
683
+ throw new AppServerError(response.status, answer.error ?? "error");
684
+ }
685
+ return {
686
+ /** Who's signed in, and what they may do, or where to sign in. */
687
+ async session() {
688
+ return await (await request("session")).json();
689
+ },
690
+ /** The site's files through the server, as an editor signed in to it reads and publishes them. */
691
+ backend(session, onSignOut) {
692
+ let pinned;
693
+ const revision = async () => {
694
+ pinned = (await (await request("revision")).json()).revision;
695
+ return pinned;
696
+ };
697
+ const at = async () => pinned ?? await revision();
698
+ return {
699
+ user: session.user,
700
+ ...session.contentOnly && { contentOnly: true },
701
+ revision,
702
+ async read(path) {
703
+ const response = await request("file", {
704
+ query: {
705
+ path,
706
+ revision: await at()
707
+ },
708
+ missingIsFine: true
709
+ });
710
+ return response.status === 404 ? void 0 : (await response.json()).content;
711
+ },
712
+ async readBytes(path) {
713
+ const response = await request("file", {
714
+ query: {
715
+ path,
716
+ revision: await at(),
717
+ as: "bytes"
718
+ },
719
+ missingIsFine: true
720
+ });
721
+ return response.status === 404 ? void 0 : decodeBase64((await response.json()).base64);
722
+ },
723
+ async list(dir) {
724
+ return (await (await request("files", { query: {
725
+ dir,
726
+ revision: await at()
727
+ } })).json()).files;
728
+ },
729
+ async readFiles(paths) {
730
+ const answer = await (await request("files", { body: {
731
+ paths,
732
+ revision: await at()
733
+ } })).json();
734
+ return new Map(Object.entries(answer.files));
735
+ },
736
+ async write(changes, { message, expectedRevision }) {
737
+ const saved = await (await request("write", { body: {
738
+ changes: changes.map((change) => "bytes" in change ? {
739
+ path: change.path,
740
+ base64: encodeBase64Bytes(change.bytes)
741
+ } : change),
742
+ message,
743
+ expectedRevision
744
+ } })).json();
745
+ pinned = saved.revision;
746
+ return saved;
747
+ },
748
+ async changedPaths(from, to) {
749
+ return (await (await request("changed", { query: {
750
+ from,
751
+ to
752
+ } })).json()).paths;
753
+ },
754
+ async deployStatus(revision) {
755
+ return await (await request("deploy", { query: { revision } })).json();
756
+ },
757
+ async history(path, { page = 1, perPage = 20 } = {}) {
758
+ return (await (await request("history", { query: {
759
+ path,
760
+ revision: await at(),
761
+ page: String(page),
762
+ perPage: String(perPage)
763
+ } })).json()).versions;
764
+ },
765
+ async readAt(path, revision) {
766
+ const response = await request("version", {
767
+ query: {
768
+ path,
769
+ revision
770
+ },
771
+ missingIsFine: true
772
+ });
773
+ return response.status === 404 ? void 0 : (await response.json()).content;
774
+ },
775
+ signOut: onSignOut
776
+ };
706
777
  }
707
- });
708
- }
709
- function collectionHref(id, tab = "entries") {
710
- return tab === "entries" ? `#/collections/${id}` : `#/collections/${id}/${tab}`;
711
- }
712
- function entryEditorHref(collection, slug) {
713
- return `#/collections/${collection}/edit?slug=${encodeURIComponent(slug)}`;
778
+ };
714
779
  }
715
780
  //#endregion
716
781
  //#region src/strings.tsx
@@ -759,6 +824,13 @@ const defaultStrings = {
759
824
  "signIn.install": "Install Goodfellow on {host}",
760
825
  "signIn.error.expired": "You were signed out because your sign-in expired. Sign in again to keep editing.",
761
826
  "signIn.error.other": "Couldn't reach {host}. Check your connection and try again.",
827
+ "signIn.app.intro": "Sign in with your account on this site to edit it.",
828
+ "signIn.app.button": "Sign in",
829
+ "signIn.app.expired": "You've been signed out. Sign in again to keep editing.",
830
+ "signIn.app.notAllowed": "You're signed in, but your account isn't allowed to edit this site. Ask one of its owners to give you access.",
831
+ "signIn.app.error": "Couldn't reach the site. Check your connection and try again.",
832
+ "owners.only": "Only the site's owners can change this.",
833
+ "owners.onlyHint": "Ask one of them if something here needs changing.",
762
834
  "account.signedInAs": "Signed in as {name}",
763
835
  "account.signOut": "Sign out",
764
836
  "deploy.building": "Updating the live site…",
@@ -1006,6 +1078,8 @@ const defaultStrings = {
1006
1078
  "demo.startOver": "Start over",
1007
1079
  "demo.startOverTitle": "Start over?",
1008
1080
  "demo.startOverBody": "Everything you've changed in this demo, including uploads, will be undone.",
1081
+ "blocks.contentOnly": "This site's blocks are added by its developer.",
1082
+ "blocks.contentOnlyHint": "Its code is kept apart from its content, so adding or removing a block is done where the code is, and shows up here once the site has been rebuilt.",
1009
1083
  "blocks.demo": "In this demo you can see which blocks there are, but not add or remove them: a block is added to a real site by updating its code.",
1010
1084
  "markdown.formatted": "Formatted",
1011
1085
  "markdown.source": "Markdown",
@@ -1116,6 +1190,8 @@ const defaultStrings = {
1116
1190
  "ai.provider.manual": "Copy and paste into a chat app (free)",
1117
1191
  "ai.provider.custom": "Another service (OpenAI-compatible)",
1118
1192
  "ai.noKey": "(free, no key needed)",
1193
+ "ai.siteKey": "(provided by this site)",
1194
+ "ai.siteKeyHint": "This site provides {service}, so you don't need a key of your own.",
1119
1195
  "ai.baseUrl": "Address of the service's API",
1120
1196
  "ai.baseUrlHint": "Such as https://example.org/v1.",
1121
1197
  "ai.key": "API key",
@@ -1227,6 +1303,11 @@ const defaultStrings = {
1227
1303
  "settings.tab.editors": "Editors",
1228
1304
  "settings.tab.updates": "Updates",
1229
1305
  "updates.intro": "Goodfellow, the software the site runs on, gets fixes and new releases. Fixes install on their own each night, and only once the site has been checked to still build with them.",
1306
+ "updates.contentOnly": "This site's code is kept apart from its content, so Goodfellow's updates are installed where the code is, by the site's own setup or its developer.",
1307
+ "updates.code": "This site's code is kept apart from its content, at {place}, where Goodfellow's updates are installed.",
1308
+ "updates.code.link": "See it on {host}",
1309
+ "updates.noAccess": "Your sign-in can't see the site's code, at {place}, where its updates are installed. Ask the site's developer to give you access to it. On GitHub, an access token needs to reach it too, or with Sign in with GitHub, Goodfellow's app needs to be installed there.",
1310
+ "updates.back.skip": "Don't install Goodfellow {version} again",
1230
1311
  "updates.local": "Updates are shown in the published site's admin panel, where you sign in with GitHub or GitLab.",
1231
1312
  "updates.demo": "Updates aren't installed in a demo.",
1232
1313
  "updates.loading": "Checking for updates…",
@@ -1585,6 +1666,146 @@ function PublishedNotice({ onHide }) {
1585
1666
  });
1586
1667
  }
1587
1668
  //#endregion
1669
+ //#region src/app-sign-in.tsx
1670
+ /**
1671
+ * Shows the site's own sign-in until someone is signed in to it and allowed to
1672
+ * edit, on a full-stack site whose server publishes for them (`api`), then
1673
+ * renders its children with a backend that reads and publishes through it.
1674
+ */
1675
+ function AppSignInGate({ api, children }) {
1676
+ const t = useStrings();
1677
+ const server = useMemo(() => appServer(api), [api]);
1678
+ const [state, setState] = useState({ status: "checking" });
1679
+ const check = useCallback(async (expired = false) => {
1680
+ try {
1681
+ const session = await server.session();
1682
+ if (session.signedIn) {
1683
+ const signOut = () => {
1684
+ if (session.signOut) location.assign(session.signOut);
1685
+ };
1686
+ setState({
1687
+ status: "signed-in",
1688
+ session,
1689
+ backend: server.backend(session, signOut)
1690
+ });
1691
+ } else setState({
1692
+ status: "signed-out",
1693
+ session,
1694
+ expired
1695
+ });
1696
+ } catch (error) {
1697
+ setState({
1698
+ status: "signed-out",
1699
+ error
1700
+ });
1701
+ }
1702
+ }, [server]);
1703
+ useEffect(() => {
1704
+ check();
1705
+ }, [check]);
1706
+ if (state.status === "checking") return /* @__PURE__ */ jsx("p", {
1707
+ className: "gfa-screen",
1708
+ children: t("signIn.checking")
1709
+ });
1710
+ if (state.status === "signed-out") {
1711
+ const { session, error, expired } = state;
1712
+ return /* @__PURE__ */ jsxs("div", {
1713
+ className: "gfa-sign-in",
1714
+ children: [/* @__PURE__ */ jsx("h1", { children: t("signIn.title") }), error !== void 0 ? /* @__PURE__ */ jsx(ErrorMessage, {
1715
+ message: t("signIn.app.error"),
1716
+ error,
1717
+ action: /* @__PURE__ */ jsx(Button, {
1718
+ onClick: () => void check(),
1719
+ children: t("action.tryAgain")
1720
+ })
1721
+ }) : session?.notAllowed ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("p", { children: t("signIn.app.notAllowed") }), session.signOut && /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx("a", {
1722
+ className: "gfa-button gfa-button-secondary",
1723
+ href: session.signOut,
1724
+ children: t("account.signOut")
1725
+ }) })] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("p", { children: t(expired ? "signIn.app.expired" : "signIn.app.intro") }), session && /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx("a", {
1726
+ className: "gfa-button gfa-button-primary",
1727
+ href: session.signIn,
1728
+ children: t("signIn.app.button")
1729
+ }) })] })]
1730
+ });
1731
+ }
1732
+ return children(state.backend, state.session, (error) => {
1733
+ if (error === void 0) state.backend.signOut();
1734
+ else check(true);
1735
+ });
1736
+ }
1737
+ //#endregion
1738
+ //#region src/router.tsx
1739
+ function parseHash(hash) {
1740
+ const [path = "", query = ""] = hash.replace(/^#\/?/, "").split("?");
1741
+ return {
1742
+ segments: path.split("/").filter(Boolean),
1743
+ params: new URLSearchParams(query)
1744
+ };
1745
+ }
1746
+ function subscribe$1(onChange) {
1747
+ window.addEventListener("hashchange", onChange);
1748
+ return () => window.removeEventListener("hashchange", onChange);
1749
+ }
1750
+ function useRoute() {
1751
+ return parseHash(useSyncExternalStore(subscribe$1, () => window.location.hash, () => ""));
1752
+ }
1753
+ const guards = /* @__PURE__ */ new Set();
1754
+ /** Whether any screen has unpublished changes. */
1755
+ function hasUnsavedChanges() {
1756
+ return [...guards].some((isDirty) => isDirty());
1757
+ }
1758
+ /**
1759
+ * Registers a screen's unpublished changes, so leaving it (inside the admin
1760
+ * panel or by closing the tab) asks for confirmation first.
1761
+ */
1762
+ function useUnsavedChanges(isDirty) {
1763
+ useEffect(() => {
1764
+ if (!isDirty) return;
1765
+ const guard = () => true;
1766
+ guards.add(guard);
1767
+ const onBeforeUnload = (event) => event.preventDefault();
1768
+ window.addEventListener("beforeunload", onBeforeUnload);
1769
+ return () => {
1770
+ guards.delete(guard);
1771
+ window.removeEventListener("beforeunload", onBeforeUnload);
1772
+ };
1773
+ }, [isDirty]);
1774
+ }
1775
+ /** Goes to another screen, asking first if there are unpublished changes. Returns whether it navigated. */
1776
+ function navigate(to, confirmLeave) {
1777
+ if (hasUnsavedChanges() && !confirmLeave()) return false;
1778
+ guards.clear();
1779
+ window.location.hash = to;
1780
+ return true;
1781
+ }
1782
+ /** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
1783
+ const SETTINGS_SECTIONS = {
1784
+ contact: "contact",
1785
+ forms: "forms"
1786
+ };
1787
+ function pageEditorHref(path) {
1788
+ return `#/pages/edit?path=${encodeURIComponent(path)}`;
1789
+ }
1790
+ /** An in-app link that asks before leaving a screen with unpublished changes. */
1791
+ function Link({ href, confirmLeave, ...props }) {
1792
+ return /* @__PURE__ */ jsx("a", {
1793
+ ...props,
1794
+ href,
1795
+ onClick: (event) => {
1796
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;
1797
+ event.preventDefault();
1798
+ navigate(href, confirmLeave);
1799
+ }
1800
+ });
1801
+ }
1802
+ function collectionHref(id, tab = "entries") {
1803
+ return tab === "entries" ? `#/collections/${id}` : `#/collections/${id}/${tab}`;
1804
+ }
1805
+ function entryEditorHref(collection, slug) {
1806
+ return `#/collections/${collection}/edit?slug=${encodeURIComponent(slug)}`;
1807
+ }
1808
+ //#endregion
1588
1809
  //#region src/blocks-screen.tsx
1589
1810
  const fetchJson = async (url) => {
1590
1811
  const response = await fetch(url);
@@ -2796,19 +3017,25 @@ function problemKey(error) {
2796
3017
  if (error instanceof SyntaxError) return "ai.error.answer";
2797
3018
  return "ai.error.service";
2798
3019
  }
2799
- function ServiceSettings({ providers, settings, onChange }) {
3020
+ function ServiceSettings({ providers, settings, onChange, siteAi }) {
2800
3021
  const t = useStrings();
2801
3022
  const info = PROVIDERS[settings.provider];
3023
+ const sitePays = siteAi?.services.includes(settings.provider) ?? false;
2802
3024
  const service = serviceName(settings.provider, t);
2803
3025
  const [models, setModels] = useState(info.models ?? []);
2804
3026
  const listId = useRef(`gfa-ai-models-${Math.random().toString(36).slice(2)}`).current;
2805
3027
  const { provider, apiKey, baseUrl } = settings;
3028
+ const server = sitePays ? siteAi?.server : void 0;
2806
3029
  useEffect(() => {
2807
3030
  let cancelled = false;
2808
3031
  const controller = new AbortController();
2809
3032
  setModels(PROVIDERS[provider].models ?? []);
2810
3033
  const timer = setTimeout(() => {
2811
- listModels({
3034
+ listModels(server ? {
3035
+ provider,
3036
+ server,
3037
+ model: ""
3038
+ } : {
2812
3039
  provider,
2813
3040
  apiKey,
2814
3041
  baseUrl,
@@ -2825,7 +3052,8 @@ function ServiceSettings({ providers, settings, onChange }) {
2825
3052
  }, [
2826
3053
  provider,
2827
3054
  apiKey,
2828
- baseUrl
3055
+ baseUrl,
3056
+ server
2829
3057
  ]);
2830
3058
  return /* @__PURE__ */ jsxs("div", {
2831
3059
  className: "gfa-form gfa-ai-settings",
@@ -2839,18 +3067,22 @@ function ServiceSettings({ providers, settings, onChange }) {
2839
3067
  onChange: (event) => onChange({ provider: event.target.value }),
2840
3068
  children: providers.map((id) => /* @__PURE__ */ jsxs("option", {
2841
3069
  value: id,
2842
- children: [serviceName(id, t), !PROVIDERS[id].needsKey && id !== "manual" && id !== "custom" ? ` ${t("ai.noKey")}` : ""]
3070
+ children: [serviceName(id, t), siteAi?.services.includes(id) ? ` ${t("ai.siteKey")}` : !PROVIDERS[id].needsKey && id !== "manual" && id !== "custom" ? ` ${t("ai.noKey")}` : ""]
2843
3071
  }, id))
2844
3072
  })
2845
3073
  }),
2846
- settings.provider === "custom" && /* @__PURE__ */ jsx(TextField, {
3074
+ sitePays && /* @__PURE__ */ jsx("p", {
3075
+ className: "gfa-hint",
3076
+ children: t("ai.siteKeyHint", { service })
3077
+ }),
3078
+ settings.provider === "custom" && !sitePays && /* @__PURE__ */ jsx(TextField, {
2847
3079
  label: t("ai.baseUrl"),
2848
3080
  hint: t("ai.baseUrlHint"),
2849
3081
  type: "url",
2850
3082
  value: settings.baseUrl ?? "",
2851
3083
  onChange: (baseUrl) => onChange({ baseUrl })
2852
3084
  }),
2853
- settings.provider !== "manual" && (info.needsKey || settings.provider === "custom") && /* @__PURE__ */ jsxs(Fragment, { children: [
3085
+ settings.provider !== "manual" && !sitePays && (info.needsKey || settings.provider === "custom") && /* @__PURE__ */ jsxs(Fragment, { children: [
2854
3086
  /* @__PURE__ */ jsx(TextField, {
2855
3087
  label: t("ai.key"),
2856
3088
  hint: t("ai.keyHint", { service }),
@@ -2919,12 +3151,23 @@ function serviceName(id, t) {
2919
3151
  /** The AI panel: describe what to write, and it's added to the page, replaces the selected block, or fills in the fields. */
2920
3152
  function AiPanel({ context }) {
2921
3153
  const t = useStrings();
2922
- const { config, publish, mediaPreviews } = useAdmin();
3154
+ const { config, publish, mediaPreviews, account } = useAdmin();
2923
3155
  const { content, media } = useSiteContent();
2924
3156
  const getPuck = useGetPuck();
2925
3157
  const selected = usePuck$1((state) => state.selectedItem);
2926
- const providers = useMemo(() => allowedProviders(config.ai ? config.ai.providers : void 0), [config.ai]);
2927
- const [settings, updateSettings] = useAiSettings(providers);
3158
+ const siteAi = account?.ai;
3159
+ const providers = useMemo(() => {
3160
+ const allowed = allowedProviders(config.ai ? config.ai.providers : void 0);
3161
+ const paid = allowed.filter((id) => siteAi?.services.includes(id));
3162
+ return [...paid, ...allowed.filter((id) => !paid.includes(id))];
3163
+ }, [config.ai, siteAi]);
3164
+ const [chosen, updateSettings] = useAiSettings(providers);
3165
+ const settings = useMemo(() => siteAi?.services.includes(chosen.provider) ? {
3166
+ ...chosen,
3167
+ apiKey: void 0,
3168
+ baseUrl: void 0,
3169
+ server: siteAi.server
3170
+ } : chosen, [chosen, siteAi]);
2928
3171
  const [photosChosen, choosePhotos] = useStockPhotoChoice();
2929
3172
  const photosOffered = !(config.ai && config.ai.stockPhotos === false);
2930
3173
  const photosAllowed = photosOffered && photosChosen;
@@ -2937,7 +3180,7 @@ function AiPanel({ context }) {
2937
3180
  const service = serviceName(settings.provider, t);
2938
3181
  const blockName = (block) => block ? getPuck().config.components[block.type]?.label ?? block.type : "";
2939
3182
  const itemName = inSentence(context.collection?.settings.entryName ?? "");
2940
- const ready = settings.provider === "manual" || Boolean(settings.model) && (!PROVIDERS[settings.provider].needsKey || Boolean(settings.apiKey));
3183
+ const ready = settings.provider === "manual" || Boolean(settings.model) && (!PROVIDERS[settings.provider].needsKey || Boolean(settings.apiKey) || Boolean(settings.server));
2941
3184
  const [settingsOpen, setSettingsOpen] = useState(!ready);
2942
3185
  useEffect(() => () => controller.current?.abort(), []);
2943
3186
  const taskFor = (text) => {
@@ -3235,8 +3478,9 @@ function AiPanel({ context }) {
3235
3478
  onToggle: (event) => setSettingsOpen(event.currentTarget.open),
3236
3479
  children: [/* @__PURE__ */ jsx("summary", { children: t("ai.settings", { service }) }), /* @__PURE__ */ jsx(ServiceSettings, {
3237
3480
  providers,
3238
- settings,
3239
- onChange: updateSettings
3481
+ settings: chosen,
3482
+ onChange: updateSettings,
3483
+ ...siteAi && { siteAi }
3240
3484
  })]
3241
3485
  })
3242
3486
  ]
@@ -4073,10 +4317,11 @@ function inSentence(word) {
4073
4317
  /** The tabs shared by a collection's screens, above the screen itself. */
4074
4318
  function CollectionLayout({ collection, tab, children }) {
4075
4319
  const t = useStrings();
4320
+ const owner = useCanChangeSettings();
4076
4321
  const tabs = [
4077
4322
  ["entries", "collection.tab.entries"],
4078
- ...collection.settings.path ? [["template", "collection.tab.template"]] : [],
4079
- ["settings", "collection.tab.settings"]
4323
+ ...owner && collection.settings.path ? [["template", "collection.tab.template"]] : [],
4324
+ ...owner ? [["settings", "collection.tab.settings"]] : []
4080
4325
  ];
4081
4326
  return /* @__PURE__ */ jsxs("div", {
4082
4327
  className: "gfa-layout-screen",
@@ -5247,13 +5492,14 @@ function NewCollectionDialog({ onClose }) {
5247
5492
  function CollectionsScreen() {
5248
5493
  const t = useStrings();
5249
5494
  const { content } = useSiteContent();
5495
+ const owner = useCanChangeSettings();
5250
5496
  const [creating, setCreating] = useState(false);
5251
5497
  return /* @__PURE__ */ jsxs("div", {
5252
5498
  className: "gfa-screen",
5253
5499
  children: [
5254
5500
  /* @__PURE__ */ jsxs("div", {
5255
5501
  className: "gfa-screen-header",
5256
- children: [/* @__PURE__ */ jsx("h1", { children: t("collections.title") }), /* @__PURE__ */ jsx(Button, {
5502
+ children: [/* @__PURE__ */ jsx("h1", { children: t("collections.title") }), owner && /* @__PURE__ */ jsx(Button, {
5257
5503
  variant: "primary",
5258
5504
  onClick: () => setCreating(true),
5259
5505
  children: t("collections.new")
@@ -8957,9 +9203,10 @@ function MovePageDialog({ page, onClose }) {
8957
9203
  const t = useStrings();
8958
9204
  const { publish } = useAdmin();
8959
9205
  const { content } = useSiteContent();
9206
+ const owner = useCanChangeSettings();
8960
9207
  const [address, setAddress] = useState(page.path);
8961
9208
  const [updateLinks, setUpdateLinks] = useState(true);
8962
- const canRedirect = page.path !== "/";
9209
+ const canRedirect = page.path !== "/" && owner;
8963
9210
  const [redirect, setRedirect] = useState(canRedirect);
8964
9211
  const [submitted, setSubmitted] = useState(false);
8965
9212
  const [busy, setBusy] = useState(false);
@@ -9302,7 +9549,7 @@ async function previousRelease(versions, current) {
9302
9549
  /** The site's Goodfellow release, the fixes and releases it can update to, and its nightly update job. */
9303
9550
  function UpdatesScreen() {
9304
9551
  const t = useStrings();
9305
- const { updates, demo } = useAdmin();
9552
+ const { updates, demo, contentOnly } = useAdmin();
9306
9553
  return /* @__PURE__ */ jsxs("div", {
9307
9554
  className: "gfa-screen",
9308
9555
  children: [
@@ -9313,7 +9560,7 @@ function UpdatesScreen() {
9313
9560
  /* @__PURE__ */ jsx(SettingsTabs, { tab: "updates" }),
9314
9561
  /* @__PURE__ */ jsxs("div", {
9315
9562
  className: "gfa-form",
9316
- children: [/* @__PURE__ */ jsx("p", { children: t("updates.intro") }), demo ? /* @__PURE__ */ jsx("p", { children: t("updates.demo") }) : updates ? /* @__PURE__ */ jsx(UpdatesPanel, {}) : /* @__PURE__ */ jsx("p", { children: t("updates.local") })]
9563
+ children: [/* @__PURE__ */ jsx("p", { children: t("updates.intro") }), demo ? /* @__PURE__ */ jsx("p", { children: t("updates.demo") }) : contentOnly && !updates ? /* @__PURE__ */ jsx("p", { children: t("updates.contentOnly") }) : updates ? /* @__PURE__ */ jsx(UpdatesPanel, {}) : /* @__PURE__ */ jsx("p", { children: t("updates.local") })]
9317
9564
  })
9318
9565
  ]
9319
9566
  });
@@ -9331,8 +9578,9 @@ function UpdatesPanel() {
9331
9578
  const load = useCallback(async () => {
9332
9579
  if (!updates) return;
9333
9580
  try {
9581
+ const code = updates.code;
9334
9582
  const [packageJson, settingsText, metadata, setup, last, people] = await Promise.all([
9335
- versions?.readAt("package.json", revision),
9583
+ code ? code.read("package.json").catch(() => void 0) : versions?.readAt("package.json", revision),
9336
9584
  readFile(UPDATES_FILE),
9337
9585
  window.fetch(`${NPM_REGISTRY}/${VERSION_PACKAGE}`, { headers: { accept: "application/vnd.npm.install-v1+json" } }).then((response) => response.ok ? response.json() : void 0).catch(() => void 0),
9338
9586
  updates.setup(),
@@ -9341,7 +9589,8 @@ function UpdatesPanel() {
9341
9589
  ]);
9342
9590
  const current = packageJson ? siteVersion(packageJson) : void 0;
9343
9591
  const settings = parseUpdateSettings(settingsText);
9344
- const previous = current && versions ? await previousRelease(versions, current).catch(() => void 0) : void 0;
9592
+ const history = code ?? versions;
9593
+ const previous = current && history ? await previousRelease(history, current).catch(() => void 0) : void 0;
9345
9594
  setLoaded({
9346
9595
  current,
9347
9596
  previous,
@@ -9387,6 +9636,20 @@ function UpdatesPanel() {
9387
9636
  setBusy(false);
9388
9637
  };
9389
9638
  const start = (target) => act(() => updates?.start(target) ?? Promise.resolve(), () => setStarted(true));
9639
+ /**
9640
+ * Goes back to an older release. Where the code is kept apart from the
9641
+ * content, the update can't change the content's settings, so the release
9642
+ * the site leaves is skipped here, as the update does elsewhere.
9643
+ */
9644
+ const goBack = (target, settings, current) => act(async () => {
9645
+ await updates?.start(target);
9646
+ if (!updates?.code || settings.skip.includes(current)) return;
9647
+ const result = await publish([updateSettingsFileChange({
9648
+ ...settings,
9649
+ skip: [...settings.skip, current]
9650
+ })], t("updates.back.skip", { version: current }));
9651
+ if (!result.ok) throw new SaveError(result.error);
9652
+ }, () => setStarted(true));
9390
9653
  const saveSettings = (settings, message) => act(async () => {
9391
9654
  const result = await publish([updateSettingsFileChange(settings)], message);
9392
9655
  if (!result.ok) throw new SaveError(result.error);
@@ -9403,7 +9666,20 @@ function UpdatesPanel() {
9403
9666
  const { current, previous, settings, available, setup, last, owner } = loaded;
9404
9667
  const needsToken = problem instanceof UpdatesError && problem.problem === "not-allowed" && ownerAccess;
9405
9668
  return /* @__PURE__ */ jsxs(Fragment, { children: [
9406
- /* @__PURE__ */ jsx("p", { children: current ? t("updates.current", { version: current }) : t("updates.unknownVersion") }),
9669
+ updates.code && /* @__PURE__ */ jsxs("p", { children: [
9670
+ t("updates.code", { place: updates.code.repo }),
9671
+ " ",
9672
+ /* @__PURE__ */ jsx("a", {
9673
+ href: updates.code.webUrl,
9674
+ target: "_blank",
9675
+ rel: "noreferrer",
9676
+ children: t("updates.code.link", { host })
9677
+ })
9678
+ ] }),
9679
+ setup === "no-access" ? /* @__PURE__ */ jsx("p", {
9680
+ className: "gfa-notice",
9681
+ children: t("updates.noAccess", { place: updates.code?.repo ?? "" })
9682
+ }) : /* @__PURE__ */ jsx("p", { children: current ? t("updates.current", { version: current }) : t("updates.unknownVersion") }),
9407
9683
  /* @__PURE__ */ jsx(OwnerTokenActive, { onForget: () => void load() }),
9408
9684
  setup === "missing" && /* @__PURE__ */ jsx("p", {
9409
9685
  className: "gfa-notice",
@@ -9417,7 +9693,7 @@ function UpdatesPanel() {
9417
9693
  children: t("updates.enable")
9418
9694
  })]
9419
9695
  }),
9420
- current && setup !== "missing" && /* @__PURE__ */ jsxs(Fragment, { children: [
9696
+ current && setup !== "missing" && setup !== "no-access" && /* @__PURE__ */ jsxs(Fragment, { children: [
9421
9697
  /* @__PURE__ */ jsx("h2", {
9422
9698
  className: "gfa-section-title",
9423
9699
  children: t("updates.fixes.title")
@@ -9480,7 +9756,7 @@ function UpdatesPanel() {
9480
9756
  /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
9481
9757
  disabled: busy || running,
9482
9758
  onClick: () => {
9483
- if (window.confirm(t("updates.back.confirm", { version: previous }))) start(previous);
9759
+ if (window.confirm(t("updates.back.confirm", { version: previous }))) goBack(previous, settings, current ?? "");
9484
9760
  },
9485
9761
  children: t("updates.back.button", { version: previous })
9486
9762
  }) })
@@ -9586,9 +9862,32 @@ function LoadError({ error }) {
9586
9862
  })]
9587
9863
  });
9588
9864
  }
9865
+ /** Shown in place of the Blocks screen where the site's code is kept in a repository of its own. */
9866
+ function BlocksInCode() {
9867
+ const t = useStrings();
9868
+ return /* @__PURE__ */ jsxs("div", {
9869
+ className: "gfa-screen",
9870
+ children: [/* @__PURE__ */ jsx("h1", { children: t("blocks.contentOnly") }), /* @__PURE__ */ jsx("p", {
9871
+ className: "gfa-hint",
9872
+ children: t("blocks.contentOnlyHint")
9873
+ })]
9874
+ });
9875
+ }
9876
+ /** Shown in place of screens only owners use, where the site's own sign-in says someone is an editor. */
9877
+ function OwnersOnly() {
9878
+ const t = useStrings();
9879
+ return /* @__PURE__ */ jsxs("div", {
9880
+ className: "gfa-screen",
9881
+ children: [/* @__PURE__ */ jsx("h1", { children: t("owners.only") }), /* @__PURE__ */ jsx("p", {
9882
+ className: "gfa-hint",
9883
+ children: t("owners.onlyHint")
9884
+ })]
9885
+ });
9886
+ }
9589
9887
  function CollectionRoute({ id, sub, slug }) {
9590
9888
  const t = useStrings();
9591
9889
  const { content } = useSiteContent();
9890
+ const owner = useCanChangeSettings();
9592
9891
  if (sub === "edit") return /* @__PURE__ */ jsx(EntryEditorScreen, {
9593
9892
  collectionId: id,
9594
9893
  slug
@@ -9601,6 +9900,7 @@ function CollectionRoute({ id, sub, slug }) {
9601
9900
  children: t("collection.back")
9602
9901
  })]
9603
9902
  });
9903
+ if ((sub === "template" || sub === "settings") && !owner) return /* @__PURE__ */ jsx(OwnersOnly, {});
9604
9904
  if (sub === "template") return /* @__PURE__ */ jsx(TemplateScreen, { collection });
9605
9905
  if (sub === "settings") return /* @__PURE__ */ jsx(CollectionSettingsScreen, { collection }, collection.id);
9606
9906
  return /* @__PURE__ */ jsx(EntriesScreen, { collection });
@@ -9608,8 +9908,11 @@ function CollectionRoute({ id, sub, slug }) {
9608
9908
  function Screen() {
9609
9909
  const { segments, params } = useRoute();
9610
9910
  const [section, sub, tab] = segments;
9911
+ const owner = useCanChangeSettings();
9912
+ const { contentOnly } = useAdmin();
9913
+ if ((section === "blocks" || section === "settings") && !owner) return /* @__PURE__ */ jsx(OwnersOnly, {});
9611
9914
  if (section === "media") return /* @__PURE__ */ jsx(MediaScreen, {});
9612
- if (section === "blocks") return /* @__PURE__ */ jsx(BlocksScreen, {});
9915
+ if (section === "blocks") return contentOnly ? /* @__PURE__ */ jsx(BlocksInCode, {}) : /* @__PURE__ */ jsx(BlocksScreen, {});
9613
9916
  if (section === "collections") return sub ? /* @__PURE__ */ jsx(CollectionRoute, {
9614
9917
  id: sub,
9615
9918
  sub: tab,
@@ -9700,7 +10003,7 @@ function AccountMenu() {
9700
10003
  title: t("account.signedInAs", { name: user.login }),
9701
10004
  children: user.name || user.login
9702
10005
  }),
9703
- /* @__PURE__ */ jsx(Button, {
10006
+ account.signOut && /* @__PURE__ */ jsx(Button, {
9704
10007
  variant: "ghost",
9705
10008
  onClick: account.signOut,
9706
10009
  children: t("account.signOut")
@@ -9740,7 +10043,8 @@ function versionsSection(file) {
9740
10043
  }
9741
10044
  function Shell() {
9742
10045
  const t = useStrings();
9743
- const { state, siteUrl } = useAdmin();
10046
+ const { state, siteUrl, contentOnly } = useAdmin();
10047
+ const owner = useCanChangeSettings();
9744
10048
  const { segments, params } = useRoute();
9745
10049
  const [section] = segments;
9746
10050
  const current = section === "versions" ? versionsSection(params.get("file") ?? "") : section === "layout" || section === "settings" || section === "collections" || section === "media" || section === "blocks" ? section : "pages";
@@ -9778,12 +10082,12 @@ function Shell() {
9778
10082
  "aria-current": current === "layout" ? "page" : void 0,
9779
10083
  children: t("nav.layout")
9780
10084
  }),
9781
- /* @__PURE__ */ jsx(AppLink, {
10085
+ owner && !contentOnly && /* @__PURE__ */ jsx(AppLink, {
9782
10086
  href: "#/blocks",
9783
10087
  "aria-current": current === "blocks" ? "page" : void 0,
9784
10088
  children: t("nav.blocks")
9785
10089
  }),
9786
- /* @__PURE__ */ jsx(AppLink, {
10090
+ owner && /* @__PURE__ */ jsx(AppLink, {
9787
10091
  href: "#/settings/general",
9788
10092
  "aria-current": current === "settings" ? "page" : void 0,
9789
10093
  children: t("nav.settings")
@@ -9825,7 +10129,7 @@ function MissingBackend() {
9825
10129
  });
9826
10130
  }
9827
10131
  /** The admin panel. Render it on its own page, such as `/admin`. */
9828
- function Admin({ config, store, host = config.backend, preview, siteUrl = "/", strings, registry }) {
10132
+ function Admin({ config, store, host = config.backend, server, preview, siteUrl = "/", strings, registry }) {
9829
10133
  useApplyThemeChoice();
9830
10134
  const demo = useMemo(() => config.demo ? createDemoStore(siteUrl, store) : void 0, [
9831
10135
  config.demo,
@@ -9856,6 +10160,31 @@ function Admin({ config, store, host = config.backend, preview, siteUrl = "/", s
9856
10160
  siteUrl,
9857
10161
  registry,
9858
10162
  children: /* @__PURE__ */ jsx(Shell, {})
10163
+ }) : server ? /* @__PURE__ */ jsx(AppSignInGate, {
10164
+ api: server,
10165
+ children: (backend, session, signOut) => /* @__PURE__ */ jsx(AdminProvider, {
10166
+ config,
10167
+ store: backend,
10168
+ account: {
10169
+ user: backend.user,
10170
+ hostName: host?.name ?? "",
10171
+ redirectAddressesListed: false,
10172
+ role: session.role,
10173
+ ...session.ai.length > 0 && { ai: {
10174
+ server: new URL(`${server.replace(/\/+$/, "")}/ai`, location.href).href,
10175
+ services: session.ai
10176
+ } },
10177
+ ...session.signOut && { signOut: () => {
10178
+ forgetAiKeys();
10179
+ signOut();
10180
+ } }
10181
+ },
10182
+ preview,
10183
+ siteUrl,
10184
+ registry,
10185
+ onSignInError: (error) => signOut(error),
10186
+ children: /* @__PURE__ */ jsx(Shell, {})
10187
+ })
9859
10188
  }) : host ? /* @__PURE__ */ jsx(SignInGate, {
9860
10189
  host,
9861
10190
  children: (backend, signOut) => /* @__PURE__ */ jsx(AdminProvider, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/admin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Goodfellow's admin panel: a visual editor for a site's pages, layout and settings.",
5
5
  "keywords": [
6
6
  "goodfellow",
@@ -49,10 +49,10 @@
49
49
  "codemirror": "6.0.2",
50
50
  "turndown": "7.2.4",
51
51
  "turndown-plugin-gfm": "1.0.2",
52
- "@goodfellow-cms/core": "0.3.0",
53
- "@goodfellow-cms/ai": "0.3.0",
54
- "@goodfellow-cms/registry": "0.3.0",
55
- "@goodfellow-cms/react": "0.3.0"
52
+ "@goodfellow-cms/ai": "0.4.0",
53
+ "@goodfellow-cms/react": "0.4.0",
54
+ "@goodfellow-cms/registry": "0.4.0",
55
+ "@goodfellow-cms/core": "0.4.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@puckeditor/core": "0.23.0",