@goodfellow-cms/admin 0.2.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.js CHANGED
@@ -1,11 +1,11 @@
1
- import { BODY_CODE_FILE, COLLECTIONS_DIR, CURRENT_VERSION, CUSTOM_CSS_FILE, ConflictError, ContentError, DEFAULT_THEME_COLORS, DEMO_CONTENT_PATH, DomainError, EditorsError, FIELD_TYPES, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, HEAD_CODE_FILE, HeadCodeError, INSTALLED_RECORD_FILE, MEDIA_DIR, MENUS_FILE, NPM_REGISTRY, REDIRECTS_FILE, REDIRECT_LINK_PLACEHOLDER, REDIRECT_MESSAGE, RegistryError, SITE_FILE, SLUG_PLACEHOLDER, SignInError, THEME_COLORS, TITLE_FIELD, UPDATES_FILE, UpdatesError, VERSION_PACKAGE, WEEKDAYS, addressPatternProblem, allPages, availableBlocks, availableUpdates, blockUses, canFormatMarkdown, checkRecords, cleanEventValue, collectionFileSchema, collectionSettingsFile, compareReleases, credentialStorage, datePart, demoContentStore, demoStore, domainZone, entryAddress, entryFile, entryTitle, escapeStyleText, eventValueProblem, findEntry, formTarget, formatDate, formatOccurrence, goodfellowRegistryUrl, googleFontsUrl, isAddressSegment, isAllDay, isDemoStore, isEmptyValue, isEventLike, isReservedPagePath, loadSiteContent, markdownToHtml, menusFileSchema, normalizeDomain, normalizePagePath, pagePathToFile, pageView, parseDemoContent, parseHeadCode, parseInstalledRecord, parseUpdateSettings, planBlockChanges, recordsInPlace, redirectFrom, redirectProblems, registrarName, releaseVersions, serializeContent, serializeMarkdownEntry, shownOccurrence, sitePages, siteSettingsSchema, siteVersion, storedCode, storedRedirects, themeToCss, todayIn, trimChars, updateSettingsFile, wallClock, weekdayName, weekdayOf, 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
- import { PageBody, SiteProvider, applyEntry, createPuckConfig, cx, mediaFieldKind, preparePage, siteMetadata } from "@goodfellow-cms/react";
4
+ import { PageBody, SiteProvider, applyEntry, createPuckConfig, createPuckConfigs, cx, mediaFieldKind, preparePage, siteMetadata } from "@goodfellow-cms/react";
5
5
  import TurndownService from "turndown";
6
6
  import { gfm } from "turndown-plugin-gfm";
7
7
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
- import { AiError, CLAUDE_MODELS, PROVIDERS, PROVIDER_IDS, buildRequest, listModels, manualPrompt, parseAnswer, runRequest, toContent, toValues } from "@goodfellow-cms/ai";
8
+ import { AiError, CLAUDE_MODELS, MAX_STOCK_PHOTOS, PROVIDERS, PROVIDER_IDS, StockPhotoError, buildRequest, downloadStockPhoto, listModels, manualPrompt, parseAnswer, pickStockPhoto, runRequest, searchStockPhotos, stockPhotoCredit, stockPhotoQueries, toContent, toValues, withStockPhotos } from "@goodfellow-cms/ai";
9
9
  import { createPortal } from "react-dom";
10
10
  import { AutoField, FieldLabel, Puck, Render, RichTextMenu, createUsePuck, legacySideBarPlugin, migrate, registerOverlayPortal, resolveAllData, useGetPuck } from "@puckeditor/core";
11
11
  //#region src/markdown-text.ts
@@ -59,439 +59,11 @@ function editedMarkdown(original, html) {
59
59
  }
60
60
  //#endregion
61
61
  //#region src/changes.ts
62
- /** `"Mass & Confession Times"` → `"mass-confession-times"`. */
63
- function slugify(text) {
64
- return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
65
- }
66
- /** The title shown for a page in lists: its own title, or its address. */
67
- function pageTitle(page) {
68
- const title = page.content.data.root.props?.title;
69
- return typeof title === "string" && title.trim() ? title.trim() : page.path;
70
- }
71
- /**
72
- * Checks an address typed for a page. Returns the normalized path, or why it
73
- * can't be used. `current` is the page's existing address, if it has one.
74
- */
75
- function checkPageAddress(input, pages, current) {
76
- let path;
77
- try {
78
- path = normalizePagePath(input.trim().startsWith("/") ? input.trim() : `/${input.trim()}`);
79
- } catch {
80
- return {
81
- ok: false,
82
- problem: "invalid"
83
- };
84
- }
85
- if (isReservedPagePath(path)) return {
86
- ok: false,
87
- problem: "reserved"
88
- };
89
- if (path !== current && pages.some((page) => page.path === path)) return {
90
- ok: false,
91
- problem: "taken"
92
- };
93
- return {
94
- ok: true,
95
- path
96
- };
97
- }
98
- /** Puck data as stored: Puck adds an empty `zones` object, which only adds noise to the file. */
99
- function storedData(data) {
100
- if (!data.zones || Object.keys(data.zones).length > 0) return data;
101
- const { zones: _empty, ...rest } = data;
102
- return rest;
103
- }
104
- function pageFileChange(path, data) {
105
- return {
106
- path: pagePathToFile(path),
107
- content: serializeContent({
108
- version: CURRENT_VERSION.page,
109
- data: storedData(data)
110
- })
111
- };
112
- }
113
- function layoutFileChange(file, data) {
114
- return {
115
- path: file,
116
- content: serializeContent({
117
- version: CURRENT_VERSION.layout,
118
- data: storedData(data)
119
- })
120
- };
121
- }
122
- /** A new, empty page with just a title. */
123
- function newPageData(title) {
124
- return {
125
- root: { props: { title } },
126
- content: []
127
- };
128
- }
129
- /** Points every menu link at `from` (or inside it) to `to` instead. Returns `undefined` if no link changed. */
130
- function updateMenuLinks(menus, from, to) {
131
- let changed = false;
132
- const move = (href) => {
133
- if (href === from || href.startsWith(`${from}/`)) {
134
- changed = true;
135
- return `${to}${href.slice(from.length)}` || "/";
136
- }
137
- return href;
138
- };
139
- const updated = Object.fromEntries(Object.entries(menus).map(([name, items]) => [name, items.map((item) => ({
140
- ...item,
141
- href: move(item.href),
142
- ...item.children && { children: item.children.map((child) => ({
143
- ...child,
144
- href: move(child.href)
145
- })) }
146
- }))]));
147
- return changed ? updated : void 0;
148
- }
149
- function menusFileChange(menus) {
150
- return {
151
- path: MENUS_FILE,
152
- content: serializeContent({
153
- version: CURRENT_VERSION.menus,
154
- menus
155
- })
156
- };
157
- }
158
- /** Writes the site's redirects, deleting the file once there are none and no message of its own. */
159
- function redirectsFileChange({ redirects, message }) {
160
- const ownMessage = message?.trim();
161
- return redirects.length > 0 || ownMessage ? {
162
- path: REDIRECTS_FILE,
163
- content: serializeContent({
164
- version: CURRENT_VERSION.redirects,
165
- redirects,
166
- ...ownMessage && { message: ownMessage }
167
- })
168
- } : {
169
- path: REDIRECTS_FILE,
170
- delete: true
171
- };
172
- }
173
- /**
174
- * The redirects once a page moves from `from` to `to`, with one from its old
175
- * address: redirects to the old address go to the new one, so visitors don't
176
- * go through two, and one from the new address goes, since the page has it now.
177
- */
178
- function movedPageRedirects(redirects, from, to) {
179
- const at = (redirect, path) => {
180
- const own = redirectFrom(redirect.from);
181
- return own.ok && own.path === path;
182
- };
183
- return [...redirects.filter((redirect) => !at(redirect, to) && !at(redirect, from)).map((redirect) => redirect.to === from ? {
184
- ...redirect,
185
- to
186
- } : redirect), {
187
- from,
188
- to
189
- }];
190
- }
191
- /**
192
- * Moves a page to a new address in one save, optionally updating menu links to
193
- * it, and sending visitors on from its old address (`redirects`, the site's
194
- * redirects as they are) unless it's the home page.
195
- */
196
- function movePageChanges(page, to, menus, updateLinks, redirects) {
197
- const changes = [{
198
- path: page.file,
199
- delete: true
200
- }, {
201
- path: pagePathToFile(to),
202
- content: serializeContent({
203
- ...page.content,
204
- version: CURRENT_VERSION.page
205
- })
206
- }];
207
- const updatedMenus = updateLinks ? updateMenuLinks(menus, page.path, to) : void 0;
208
- if (updatedMenus) changes.push(menusFileChange(updatedMenus));
209
- if (redirects && page.path !== "/") changes.push(redirectsFileChange({
210
- ...redirects,
211
- redirects: movedPageRedirects(redirects.redirects, page.path, to)
212
- }));
213
- return changes;
214
- }
215
- function siteSettingsFileChange(settings) {
216
- return {
217
- path: SITE_FILE,
218
- content: serializeContent({
219
- ...settings,
220
- version: CURRENT_VERSION.site
221
- })
222
- };
223
- }
224
- /** Whether fixes install each night, and the releases updates skip. */
225
- function updateSettingsFileChange(settings) {
226
- return {
227
- path: UPDATES_FILE,
228
- content: updateSettingsFile(settings)
229
- };
230
- }
231
- /** Custom CSS as stored: ending in one newline, or empty. */
232
- function storedCss(css) {
233
- return css.trim() ? `${css.trimEnd()}\n` : "";
234
- }
235
- function customCssFileChange(css) {
236
- const stored = storedCss(css);
237
- return stored ? {
238
- path: CUSTOM_CSS_FILE,
239
- content: stored
240
- } : {
241
- path: CUSTOM_CSS_FILE,
242
- delete: true
243
- };
244
- }
245
- /** Writes the site's own code for the head and the end of the body, deleting a file once its code is gone. */
246
- function codeFileChanges(code, current = {
247
- head: "",
248
- body: ""
249
- }) {
250
- const changes = [];
251
- for (const [path, next, before] of [[
252
- HEAD_CODE_FILE,
253
- code.head,
254
- current.head
255
- ], [
256
- BODY_CODE_FILE,
257
- code.body,
258
- current.body
259
- ]]) {
260
- const stored = storedCode(next);
261
- if (stored === storedCode(before)) continue;
262
- changes.push(stored ? {
263
- path,
264
- content: stored
265
- } : {
266
- path,
267
- delete: true
268
- });
269
- }
270
- return changes;
271
- }
272
- /** Writes a collection's settings file. */
273
- function collectionFileChange(id, settings) {
274
- return {
275
- path: collectionSettingsFile(id),
276
- content: serializeContent({
277
- ...settings,
278
- version: CURRENT_VERSION.collection,
279
- template: storedData(settings.template)
280
- })
281
- };
282
- }
283
- /** An entry's values as stored: fields left empty are left out of the file. */
284
- function storedEntryFields(values) {
285
- return Object.fromEntries(Object.entries(values).filter(([, value]) => !isEmptyValue(value)));
286
- }
287
- /**
288
- * Writes an entry's file: JSON, or in a collection of Markdown files, Markdown
289
- * whose body is the body field's value (Markdown already).
290
- */
291
- function entryFileChange(collection, slug, values) {
292
- const fields = storedEntryFields(values);
293
- const { settings } = collection;
294
- return settings.markdown ? {
295
- path: entryFile(collection.id, slug, "markdown"),
296
- content: serializeMarkdownEntry(settings, fields)
297
- } : {
298
- path: entryFile(collection.id, slug),
299
- content: serializeContent({
300
- version: CURRENT_VERSION.entry,
301
- fields
302
- })
303
- };
304
- }
305
- /** A new collection's settings: a title and some text, shown by a template that lists every field. */
306
- function newCollectionSettings({ name, entryName, path, withEntryFields, kind = "blank", blocks = [] }) {
307
- if (kind === "events") return eventsCollectionSettings({
308
- name,
309
- entryName,
310
- path,
311
- withEntryFields,
312
- blocks
313
- });
314
- const fields = [{
315
- name: TITLE_FIELD,
316
- label: "Title",
317
- type: "text",
318
- required: true
319
- }, {
320
- name: "text",
321
- label: "Text",
322
- type: "richtext"
323
- }];
324
- const content = withEntryFields ? [{
325
- type: "EntryField",
326
- props: {
327
- id: "title",
328
- field: TITLE_FIELD,
329
- style: "title",
330
- className: "mx-auto max-w-3xl px-4 pt-12 pb-4"
331
- }
332
- }, {
333
- type: "EntryField",
334
- props: {
335
- id: "text",
336
- field: "text",
337
- style: "text",
338
- className: "mx-auto max-w-3xl px-4 pb-12"
339
- }
340
- }] : [];
341
- return {
342
- version: 1,
343
- name,
344
- entryName,
345
- ...path !== void 0 && { path },
346
- fields,
347
- template: {
348
- root: { props: { title: `{${TITLE_FIELD}}` } },
349
- content
350
- }
351
- };
352
- }
353
- /** A collection of events: when each happens, where, a summary, a picture and details, in calendars. */
354
- function eventsCollectionSettings({ name, entryName, path, withEntryFields, blocks }) {
355
- const fields = [
356
- {
357
- name: TITLE_FIELD,
358
- label: "Title",
359
- type: "text",
360
- required: true
361
- },
362
- {
363
- name: "when",
364
- label: "Date and time",
365
- type: "event",
366
- required: true
367
- },
368
- {
369
- name: "place",
370
- label: "Place",
371
- type: "text",
372
- hint: "Such as the hall, or an address."
373
- },
374
- {
375
- name: "summary",
376
- label: "Summary",
377
- type: "textarea",
378
- hint: "One or two sentences, shown in calendars and lists."
379
- },
380
- {
381
- name: "image",
382
- label: "Picture",
383
- type: "image"
384
- },
385
- {
386
- name: "text",
387
- label: "Details",
388
- type: "richtext"
389
- }
390
- ];
391
- const field = (id, name, style, className) => ({
392
- type: "EntryField",
393
- props: {
394
- id,
395
- field: name,
396
- style,
397
- className
398
- }
399
- });
400
- const content = withEntryFields ? [
401
- field("when", "when", "small", "mx-auto max-w-3xl px-4 pt-12"),
402
- field("title", TITLE_FIELD, "title", "mx-auto max-w-3xl px-4 pt-2 pb-2"),
403
- field("place", "place", "small", "mx-auto max-w-3xl px-4"),
404
- ...blocks.includes("AddToCalendar") ? [{
405
- type: "AddToCalendar",
406
- props: {
407
- id: "add",
408
- addLabel: "Add to calendar",
409
- otherCalendarsLabel: "Apple Calendar and others",
410
- className: "mx-auto max-w-3xl px-4 pt-4"
411
- }
412
- }] : [],
413
- field("image", "image", "text", "mx-auto max-w-3xl px-4 pt-8"),
414
- field("text", "text", "text", "mx-auto max-w-3xl px-4 py-8")
415
- ] : [];
416
- return {
417
- version: 1,
418
- name,
419
- entryName,
420
- ...path !== void 0 && { path },
421
- fields,
422
- sort: {
423
- field: "when",
424
- order: "asc"
425
- },
426
- calendar: {
427
- when: "when",
428
- place: "place",
429
- summary: "summary"
430
- },
431
- template: {
432
- root: { props: {
433
- title: `{${TITLE_FIELD}}`,
434
- description: "{summary}"
435
- } },
436
- content
437
- }
438
- };
439
- }
440
- /** The address pattern for entries whose addresses start with `prefix`: `/videos` → `/videos/{slug}`. */
441
- function addressPatternFor(prefix) {
442
- const trimmed = prefix.trim().replace(/\/+$/, "");
443
- return `${trimmed.startsWith("/") ? "" : "/"}${trimmed}/${SLUG_PLACEHOLDER}`;
444
- }
445
- /** A name for a collection's folder, a field or an entry that isn't taken yet: `talk`, then `talk-2`, `talk-3`… */
446
- function uniqueName(base, taken, fallback = "item") {
447
- const used = new Set(taken);
448
- const start = base || fallback;
449
- if (!used.has(start)) return start;
450
- for (let n = 2;; n++) if (!used.has(`${start}-${n}`)) return `${start}-${n}`;
451
- }
452
62
  /**
453
- * Checks the name an entry's file and address use. `addresses` are the
454
- * addresses already in use on the site; `current` is the entry's own name.
63
+ * The file changes the admin panel saves. Most are built by core's helpers,
64
+ * which the Claude connector shares; converting a collection's items between
65
+ * Markdown and formatted text needs the browser's HTML handling, so it's here.
455
66
  */
456
- function checkEntrySlug(input, collection, addresses, current) {
457
- const slug = input.trim();
458
- if (!isAddressSegment(slug)) return {
459
- ok: false,
460
- problem: "invalid"
461
- };
462
- if (slug !== current?.slug && collection.entries.some((entry) => entry.slug === slug)) return {
463
- ok: false,
464
- problem: "taken"
465
- };
466
- if (collection.settings.path === void 0) return {
467
- ok: true,
468
- slug
469
- };
470
- const path = entryAddress(collection.settings.path, slug);
471
- if (isReservedPagePath(path)) return {
472
- ok: false,
473
- problem: "reserved"
474
- };
475
- if (path !== current?.path && addresses.includes(path)) return {
476
- ok: false,
477
- problem: "taken"
478
- };
479
- return {
480
- ok: true,
481
- slug,
482
- path
483
- };
484
- }
485
- /** Renames an entry, which changes its address, optionally updating menu links to it. */
486
- function moveEntryChanges(collection, entry, slug, path, menus, updateLinks) {
487
- const changes = [{
488
- path: entry.file,
489
- delete: true
490
- }, entryFileChange(collection, slug, entry.content.fields)];
491
- const updatedMenus = updateLinks && entry.path && path ? updateMenuLinks(menus, entry.path, path) : void 0;
492
- if (updatedMenus) changes.push(menusFileChange(updatedMenus));
493
- return changes;
494
- }
495
67
  /**
496
68
  * A stored value as it fits its collection's field now, or `undefined` if it
497
69
  * doesn't: the field must exist, and a choice must still be offered. Tags
@@ -544,13 +116,6 @@ function collectionSettingsChanges(collection, settings) {
544
116
  }
545
117
  return changes;
546
118
  }
547
- /** Deletes a collection and all its entries. */
548
- function deleteCollectionChanges(collection) {
549
- return [collection.file, ...collection.entries.map((entry) => entry.file)].map((path) => ({
550
- path,
551
- delete: true
552
- }));
553
- }
554
119
  //#endregion
555
120
  //#region src/media.ts
556
121
  /** Files in `public/media/` are served from `/media/` on the site. */
@@ -617,7 +182,8 @@ function uploadName(original, taken) {
617
182
  const extension = extensionOf(original).replace(/^jpeg$/, "jpg");
618
183
  const base = slugify(original.slice(0, original.length - extensionOf(original).length - 1) || original) || "file";
619
184
  const names = new Set([...taken].map((name) => name.toLowerCase()));
620
- return `${uniqueName(base, new Set([...names].filter((name) => name.endsWith(`.${extension}`)).map((name) => name.slice(0, -extension.length - 1))))}.${extension}`;
185
+ const stems = new Set([...names].filter((name) => name.endsWith(`.${extension}`)).map((name) => name.slice(0, -extension.length - 1)));
186
+ return `${uniqueName(base, stems)}.${extension}`;
621
187
  }
622
188
  /** Where a media file is used, by name, so editors know what deleting or replacing it affects. */
623
189
  function mediaUsage(content, url, names) {
@@ -819,7 +385,7 @@ function AdminProvider({ config, store, account, preview, siteUrl, registry, onS
819
385
  const templateConfig = useMemo(() => createPuckConfig(config, "template"), [config]);
820
386
  const mediaPreviews = useMemo(() => createMediaPreviews(store, siteUrl), [store, siteUrl]);
821
387
  const registries = useMemo(() => ({
822
- [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.2.0"),
388
+ [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.4.0"),
823
389
  ...config.registries
824
390
  }), [registry, config.registries]);
825
391
  const favicon = state.status === "ready" ? state.content.settings.favicon : void 0;
@@ -832,11 +398,14 @@ function AdminProvider({ config, store, account, preview, siteUrl, registry, onS
832
398
  const changedPaths = useMemo(() => isGitBackend(store) ? (from, to) => store.changedPaths(from, to) : void 0, [store]);
833
399
  const editors = isGitBackend(store) ? store.editors : void 0;
834
400
  const updates = isGitBackend(store) ? store.updates : void 0;
401
+ const contentOnly = isGitBackend(store) && store.contentOnly === true;
835
402
  const backendOwner = isGitBackend(store) ? store.ownerAccess : void 0;
836
403
  const [ownerActive, setOwnerActive] = useState(backendOwner?.active ?? false);
837
404
  const ownerAccess = useMemo(() => backendOwner && {
838
405
  tokenLink: backendOwner.tokenLink,
839
406
  active: ownerActive,
407
+ problem: backendOwner.problem,
408
+ ...backendOwner.startRedirect && { startRedirect: (options) => backendOwner.startRedirect?.(options) ?? Promise.resolve() },
840
409
  async applyToken(token) {
841
410
  await backendOwner.applyToken(token);
842
411
  setOwnerActive(true);
@@ -939,7 +508,8 @@ function AdminProvider({ config, store, account, preview, siteUrl, registry, onS
939
508
  changedPaths,
940
509
  editors,
941
510
  ownerAccess,
942
- updates
511
+ updates,
512
+ contentOnly
943
513
  }), [
944
514
  config,
945
515
  pageConfig,
@@ -962,13 +532,18 @@ function AdminProvider({ config, store, account, preview, siteUrl, registry, onS
962
532
  changedPaths,
963
533
  editors,
964
534
  ownerAccess,
965
- updates
535
+ updates,
536
+ contentOnly
966
537
  ]);
967
538
  return /* @__PURE__ */ jsx(AdminContext.Provider, {
968
539
  value,
969
540
  children
970
541
  });
971
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
+ }
972
547
  function useAdmin() {
973
548
  const value = useContext(AdminContext);
974
549
  if (!value) throw new Error("useAdmin() must be used inside <Admin>.");
@@ -1050,6 +625,7 @@ function useAiSettings(providers) {
1050
625
  else storage.clear();
1051
626
  }
1052
627
  savePreferences({
628
+ ...preferences,
1053
629
  provider,
1054
630
  models: {
1055
631
  ...preferences.models,
@@ -1061,76 +637,145 @@ function useAiSettings(providers) {
1061
637
  });
1062
638
  }, [])];
1063
639
  }
1064
- //#endregion
1065
- //#region src/router.tsx
1066
- function parseHash(hash) {
1067
- const [path = "", query = ""] = hash.replace(/^#\/?/, "").split("?");
1068
- return {
1069
- segments: path.split("/").filter(Boolean),
1070
- params: new URLSearchParams(query)
1071
- };
1072
- }
1073
- function subscribe$1(onChange) {
1074
- window.addEventListener("hashchange", onChange);
1075
- return () => window.removeEventListener("hashchange", onChange);
1076
- }
1077
- function useRoute() {
1078
- return parseHash(useSyncExternalStore(subscribe$1, () => window.location.hash, () => ""));
1079
- }
1080
- const guards = /* @__PURE__ */ new Set();
1081
- /** Whether any screen has unpublished changes. */
1082
- function hasUnsavedChanges() {
1083
- return [...guards].some((isDirty) => isDirty());
1084
- }
1085
- /**
1086
- * Registers a screen's unpublished changes, so leaving it (inside the admin
1087
- * panel or by closing the tab) asks for confirmation first.
1088
- */
1089
- function useUnsavedChanges(isDirty) {
1090
- useEffect(() => {
1091
- if (!isDirty) return;
1092
- const guard = () => true;
1093
- guards.add(guard);
1094
- const onBeforeUnload = (event) => event.preventDefault();
1095
- window.addEventListener("beforeunload", onBeforeUnload);
1096
- return () => {
1097
- guards.delete(guard);
1098
- window.removeEventListener("beforeunload", onBeforeUnload);
1099
- };
1100
- }, [isDirty]);
1101
- }
1102
- /** Goes to another screen, asking first if there are unpublished changes. Returns whether it navigated. */
1103
- function navigate(to, confirmLeave) {
1104
- if (hasUnsavedChanges() && !confirmLeave()) return false;
1105
- guards.clear();
1106
- window.location.hash = to;
1107
- return true;
640
+ /** Whether the editor lets the AI find openly licensed photos, remembered in this browser. On at first. */
641
+ function useStockPhotoChoice() {
642
+ const [on, setOn] = useState(() => loadPreferences().stockPhotos !== false);
643
+ return [on, useCallback((next) => {
644
+ setOn(next);
645
+ savePreferences({
646
+ ...loadPreferences(),
647
+ stockPhotos: next
648
+ });
649
+ }, [])];
1108
650
  }
1109
- /** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
1110
- const SETTINGS_SECTIONS = {
1111
- contact: "contact",
1112
- forms: "forms"
651
+ //#endregion
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
+ }
1113
663
  };
1114
- function pageEditorHref(path) {
1115
- return `#/pages/edit?path=${encodeURIComponent(path)}`;
1116
- }
1117
- /** An in-app link that asks before leaving a screen with unpublished changes. */
1118
- function Link({ href, confirmLeave, ...props }) {
1119
- return /* @__PURE__ */ jsx("a", {
1120
- ...props,
1121
- href,
1122
- onClick: (event) => {
1123
- if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;
1124
- event.preventDefault();
1125
- 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
+ };
1126
777
  }
1127
- });
1128
- }
1129
- function collectionHref(id, tab = "entries") {
1130
- return tab === "entries" ? `#/collections/${id}` : `#/collections/${id}/${tab}`;
1131
- }
1132
- function entryEditorHref(collection, slug) {
1133
- return `#/collections/${collection}/edit?slug=${encodeURIComponent(slug)}`;
778
+ };
1134
779
  }
1135
780
  //#endregion
1136
781
  //#region src/strings.tsx
@@ -1170,8 +815,22 @@ const defaultStrings = {
1170
815
  "signIn.error.no-access": "That account can't see this site. Make sure the token was given access to it.",
1171
816
  "signIn.error.cant-publish": "That account can see the site but isn't allowed to change it. Ask the site's owner to give it permission.",
1172
817
  "signIn.error.redirect-failed": "Signing in didn't finish. Try again.",
818
+ "signIn.error.not-installed": "Goodfellow isn't installed on this site on {host} yet, so editors can't sign in with {host}. If you own the site, install it now, which signs you in too. Otherwise, ask an owner to, or sign in with an access token.",
819
+ "signIn.error.no-address": "Signing in with {host} needs the site's address in Site settings. Sign in with an access token for now, and add the address there.",
820
+ "signIn.error.wrong-address": "Signing in with {host} works only at the site's own address, as Site settings give it. Sign in there, or with an access token here.",
821
+ "signIn.error.local-address": "Signing in with {host} works only on the published site. Sign in with an access token here.",
822
+ "signIn.error.not-owner": "That account isn't an owner of this site on {host}.",
823
+ "signIn.error.unavailable": "Signing in with {host} isn't available right now, perhaps because it's been used as much as it can be today. Sign in with an access token instead.",
824
+ "signIn.install": "Install Goodfellow on {host}",
1173
825
  "signIn.error.expired": "You were signed out because your sign-in expired. Sign in again to keep editing.",
1174
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.",
1175
834
  "account.signedInAs": "Signed in as {name}",
1176
835
  "account.signOut": "Sign out",
1177
836
  "deploy.building": "Updating the live site…",
@@ -1419,6 +1078,8 @@ const defaultStrings = {
1419
1078
  "demo.startOver": "Start over",
1420
1079
  "demo.startOverTitle": "Start over?",
1421
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.",
1422
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.",
1423
1084
  "markdown.formatted": "Formatted",
1424
1085
  "markdown.source": "Markdown",
@@ -1496,6 +1157,7 @@ const defaultStrings = {
1496
1157
  "media.pickerTitleVideo": "Choose a video",
1497
1158
  "media.pickerEmpty": "Nothing has been uploaded yet. Upload a file to use it here.",
1498
1159
  "media.clear": "Remove",
1160
+ "media.credit": "Credit: {credit}",
1499
1161
  "ai.label": "AI",
1500
1162
  "ai.title": "Write with AI",
1501
1163
  "ai.mode.add": "Describe what to add to this page, and AI will write it at the end of the page.",
@@ -1505,6 +1167,15 @@ const defaultStrings = {
1505
1167
  "ai.placeholder.add": "For example: a section about our Christmas Mass schedule, with a heading, a short welcome and a button to the contact page.",
1506
1168
  "ai.placeholder.edit": "For example: make it shorter and friendlier, or translate it into Spanish.",
1507
1169
  "ai.placeholder.fill": "For example: Fr. Smith's homily from Sunday, about hope. Paste any notes here.",
1170
+ "ai.photos": "Find openly licensed photos where the media library has none that fit",
1171
+ "ai.photosHint": "Photos come from Openverse and are added to the media library. Pages that use them credit who made them, as their licenses require.",
1172
+ "ai.photos.finding": "Finding photos…",
1173
+ "ai.photos.message": "Add photos from Openverse: {names}",
1174
+ "ai.photos.added": "Photos from Openverse added to the media library: {names}.",
1175
+ "ai.photos.missing": "No photo was found for {count} of the images, so they're left empty.",
1176
+ "ai.photos.error.busy": "Openverse has had too many requests from this computer, so some images are left empty. Try again later, or choose them from the media library.",
1177
+ "ai.photos.error.unreachable": "Couldn't reach Openverse, so some images are left empty. Check your connection, or choose them from the media library.",
1178
+ "ai.photos.error.publish": "The photos found couldn't be added to the media library, so their images are left empty.",
1508
1179
  "ai.write": "Write it",
1509
1180
  "ai.change": "Change it",
1510
1181
  "ai.fill": "Fill it in",
@@ -1519,6 +1190,8 @@ const defaultStrings = {
1519
1190
  "ai.provider.manual": "Copy and paste into a chat app (free)",
1520
1191
  "ai.provider.custom": "Another service (OpenAI-compatible)",
1521
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.",
1522
1195
  "ai.baseUrl": "Address of the service's API",
1523
1196
  "ai.baseUrlHint": "Such as https://example.org/v1.",
1524
1197
  "ai.key": "API key",
@@ -1612,6 +1285,10 @@ const defaultStrings = {
1612
1285
  "domain.error.invalid": "{host} doesn't accept this domain.",
1613
1286
  "domain.error.not-ready": "The HTTPS certificate isn't ready yet. Try again in a while.",
1614
1287
  "domain.error.other": "Something went wrong.",
1288
+ "owner.signIn.title": "Sign in as an owner",
1289
+ "owner.signIn.intro": "Inviting and removing editors, and connecting a domain, need an owner's access on {host}, which everyday sign-in doesn't have. Sign in as an owner for this tab only.",
1290
+ "owner.signIn.button": "Sign in as an owner with {host}",
1291
+ "owner.signIn.orToken": "Or use an owner token",
1615
1292
  "owner.token.title": "Use an owner token",
1616
1293
  "owner.token.intro": "Inviting and removing editors, and connecting a domain, need more access on {host} than signing in has. Create a second token for them. It's kept only until you close this tab, and stops working after a day; you can delete it on {host} sooner.",
1617
1294
  "owner.token.create": "Create an owner token on {host}",
@@ -1626,6 +1303,11 @@ const defaultStrings = {
1626
1303
  "settings.tab.editors": "Editors",
1627
1304
  "settings.tab.updates": "Updates",
1628
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",
1629
1311
  "updates.local": "Updates are shown in the published site's admin panel, where you sign in with GitHub or GitLab.",
1630
1312
  "updates.demo": "Updates aren't installed in a demo.",
1631
1313
  "updates.loading": "Checking for updates…",
@@ -1733,6 +1415,8 @@ const defaultStrings = {
1733
1415
  "general.favicon": "Browser tab icon",
1734
1416
  "general.logo": "Logo",
1735
1417
  "general.logoAlt": "Logo description",
1418
+ "general.logoDark": "Logo in dark mode",
1419
+ "general.logoDarkHint": "A version of the logo that shows up on dark backgrounds, such as a white one. Leave it empty to use the same logo.",
1736
1420
  "general.socialImage": "Image when shared on social media",
1737
1421
  "general.contact": "Contact details",
1738
1422
  "general.contactHint": "Shown wherever a Contact details block is placed, such as the footer.",
@@ -1787,7 +1471,20 @@ const defaultStrings = {
1787
1471
  "theme.color.accentForeground": "Text on highlight",
1788
1472
  "theme.color.border": "Borders",
1789
1473
  "theme.default": "Default",
1474
+ "theme.darkMode": "Dark mode",
1475
+ "theme.darkModeOn": "Follow each visitor's light or dark setting",
1476
+ "theme.darkModeHint": "Off by default, so the site is always light. When it's on, visitors whose computer or phone is set to dark see the site in the colors below.",
1477
+ "theme.darkColors": "Colors in dark mode",
1478
+ "theme.preview": "Preview",
1479
+ "theme.previewLight": "Light",
1480
+ "theme.previewDark": "Dark",
1790
1481
  "theme.pickColor": "Choose a color for {name}",
1482
+ "theme.code": "Code colors",
1483
+ "theme.codeHint": "The colors of code in Code blocks and Markdown. Code that looks like the page uses the light colors, and the dark colors in dark mode.",
1484
+ "theme.codeLight": "Light colors",
1485
+ "theme.codeDark": "Dark colors",
1486
+ "theme.codeBackground": "Show code on the subtle background color",
1487
+ "theme.codeBackgroundHint": "Code that looks like the page sits on the site's Subtle background color instead of its colors' own background, so it matches the rest of the site. Check that it's still easy to read.",
1791
1488
  "menus.intro": "Menus are lists of links. Show one anywhere with the Menu block, using its name.",
1792
1489
  "menus.empty": "There are no menus yet.",
1793
1490
  "menus.add": "Add menu",
@@ -1969,41 +1666,181 @@ function PublishedNotice({ onHide }) {
1969
1666
  });
1970
1667
  }
1971
1668
  //#endregion
1972
- //#region src/blocks-screen.tsx
1973
- const fetchJson = async (url) => {
1974
- const response = await fetch(url);
1975
- if (!response.ok) throw new Error(`${url} answered ${response.status}.`);
1976
- return response.json();
1977
- };
1978
- /** What went wrong with a block registry, in plain words. */
1979
- function problemText$1(t, problem, name) {
1980
- const key = `blocks.error.${problem.code}`;
1981
- if (problem.code === "packages") return t(key, { packages: problem.packages.join(", ") });
1982
- if (problem.code === "file-exists" || problem.code === "unsupported-file") return t(key, { path: problem.path });
1983
- return t(key, { name });
1984
- }
1985
- /** The places a block is used, as they're named elsewhere in the admin panel. */
1986
- function placesText(t, uses) {
1987
- return uses.map((use) => {
1988
- if (use.kind === "page") return use.title ? `${use.title} (${use.path})` : use.path;
1989
- if (use.kind === "template") return t("blocks.usedTemplate", { name: use.name });
1990
- return t(use.kind === "header" ? "layout.header" : "layout.footer");
1991
- }).join(", ");
1992
- }
1993
- /** Says why a block that's in use can't be removed yet. */
1994
- function InUseDialog({ name, title, onClose }) {
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 }) {
1995
1676
  const t = useStrings();
1996
- const { content } = useSiteContent();
1997
- const uses = useMemo(() => blockUses(content, name), [content, name]);
1998
- return /* @__PURE__ */ jsxs(Dialog, {
1999
- title: t("blocks.inUseTitle", { name: title }),
2000
- onClose,
2001
- children: [/* @__PURE__ */ jsx("p", { children: t("blocks.inUse", {
2002
- name: title,
2003
- places: placesText(t, uses)
2004
- }) }), /* @__PURE__ */ jsx("div", {
2005
- className: "gfa-dialog-actions",
2006
- children: /* @__PURE__ */ jsx(Button, {
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
1809
+ //#region src/blocks-screen.tsx
1810
+ const fetchJson = async (url) => {
1811
+ const response = await fetch(url);
1812
+ if (!response.ok) throw new Error(`${url} answered ${response.status}.`);
1813
+ return response.json();
1814
+ };
1815
+ /** What went wrong with a block registry, in plain words. */
1816
+ function problemText$1(t, problem, name) {
1817
+ const key = `blocks.error.${problem.code}`;
1818
+ if (problem.code === "packages") return t(key, { packages: problem.packages.join(", ") });
1819
+ if (problem.code === "file-exists" || problem.code === "unsupported-file") return t(key, { path: problem.path });
1820
+ return t(key, { name });
1821
+ }
1822
+ /** The places a block is used, as they're named elsewhere in the admin panel. */
1823
+ function placesText(t, uses) {
1824
+ return uses.map((use) => {
1825
+ if (use.kind === "page") return use.title ? `${use.title} (${use.path})` : use.path;
1826
+ if (use.kind === "template") return t("blocks.usedTemplate", { name: use.name });
1827
+ return t(use.kind === "header" ? "layout.header" : "layout.footer");
1828
+ }).join(", ");
1829
+ }
1830
+ /** Says why a block that's in use can't be removed yet. */
1831
+ function InUseDialog({ name, title, onClose }) {
1832
+ const t = useStrings();
1833
+ const { content } = useSiteContent();
1834
+ const uses = useMemo(() => blockUses(content, name), [content, name]);
1835
+ return /* @__PURE__ */ jsxs(Dialog, {
1836
+ title: t("blocks.inUseTitle", { name: title }),
1837
+ onClose,
1838
+ children: [/* @__PURE__ */ jsx("p", { children: t("blocks.inUse", {
1839
+ name: title,
1840
+ places: placesText(t, uses)
1841
+ }) }), /* @__PURE__ */ jsx("div", {
1842
+ className: "gfa-dialog-actions",
1843
+ children: /* @__PURE__ */ jsx(Button, {
2007
1844
  onClick: onClose,
2008
1845
  children: t("action.close")
2009
1846
  })
@@ -2293,6 +2130,68 @@ function AppLink(props) {
2293
2130
  });
2294
2131
  }
2295
2132
  //#endregion
2133
+ //#region src/theme.ts
2134
+ const KEY = "goodfellow-admin-theme";
2135
+ const listeners = /* @__PURE__ */ new Set();
2136
+ function read() {
2137
+ try {
2138
+ const saved = localStorage.getItem(KEY);
2139
+ return saved === "light" || saved === "dark" ? saved : "system";
2140
+ } catch {
2141
+ return "system";
2142
+ }
2143
+ }
2144
+ let current;
2145
+ function choice() {
2146
+ current ??= read();
2147
+ return current;
2148
+ }
2149
+ /** Shows the choice: the stylesheet's colors follow `data-gfa-theme`, or the computer's setting without it. */
2150
+ function apply(next) {
2151
+ const root = document.documentElement;
2152
+ if (next === "system") delete root.dataset.gfaTheme;
2153
+ else root.dataset.gfaTheme = next;
2154
+ }
2155
+ function setThemeChoice(next) {
2156
+ current = next;
2157
+ try {
2158
+ if (next === "system") localStorage.removeItem(KEY);
2159
+ else localStorage.setItem(KEY, next);
2160
+ } catch {}
2161
+ apply(next);
2162
+ for (const listener of listeners) listener();
2163
+ }
2164
+ function subscribe(listener) {
2165
+ listeners.add(listener);
2166
+ const media = matchMedia("(prefers-color-scheme: dark)");
2167
+ media.addEventListener("change", listener);
2168
+ return () => {
2169
+ listeners.delete(listener);
2170
+ media.removeEventListener("change", listener);
2171
+ };
2172
+ }
2173
+ /** The choice, kept in this browser. */
2174
+ function useThemeChoice() {
2175
+ return useSyncExternalStore(subscribe, choice, () => "system");
2176
+ }
2177
+ /** Applies the choice to the page while the admin panel is open. Used once, by the admin panel itself. */
2178
+ function useApplyThemeChoice() {
2179
+ const value = useThemeChoice();
2180
+ useEffect(() => {
2181
+ apply(value);
2182
+ return () => {
2183
+ delete document.documentElement.dataset.gfaTheme;
2184
+ };
2185
+ }, [value]);
2186
+ }
2187
+ /** Whether the admin panel is dark at the moment, by choice or by the computer's setting. */
2188
+ function useDarkMode() {
2189
+ return useSyncExternalStore(subscribe, () => {
2190
+ const value = choice();
2191
+ return value === "dark" || value === "system" && matchMedia("(prefers-color-scheme: dark)").matches;
2192
+ }, () => false);
2193
+ }
2194
+ //#endregion
2296
2195
  //#region src/preview.tsx
2297
2196
  function upsert(doc, key, tag, setup) {
2298
2197
  let element = doc.head.querySelector(`[data-gf="${key}"]`);
@@ -2310,6 +2209,7 @@ function upsert(doc, key, tag, setup) {
2310
2209
  * browser too, so classes typed in the editor take effect before the site is rebuilt.
2311
2210
  */
2312
2211
  function installPreviewStyles(doc, options, styles) {
2212
+ doc.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, styles.theme.darkMode ? styles.scheme : "light");
2313
2213
  options.stylesheets.forEach((href, index) => {
2314
2214
  upsert(doc, `stylesheet-${index}`, "link", (link) => {
2315
2215
  link.rel = "stylesheet";
@@ -2351,19 +2251,25 @@ function preventNavigation(doc) {
2351
2251
  /** Applies preview styles to a document whenever the theme or custom CSS change, and keeps its media showing. */
2352
2252
  function usePreviewStyles(doc, styles) {
2353
2253
  const { preview, mediaPreviews } = useAdmin();
2254
+ const dark = useDarkMode();
2255
+ const scheme = styles.scheme ?? (dark ? "dark" : "light");
2354
2256
  useEffect(() => {
2355
2257
  if (!doc) return;
2356
2258
  preventNavigation(doc);
2357
- installPreviewStyles(doc, preview, styles);
2259
+ installPreviewStyles(doc, preview, {
2260
+ ...styles,
2261
+ scheme
2262
+ });
2358
2263
  }, [
2359
2264
  doc,
2360
2265
  preview,
2361
- styles
2266
+ styles,
2267
+ scheme
2362
2268
  ]);
2363
2269
  useEffect(() => doc ? showMediaInPreview(doc, mediaPreviews) : void 0, [doc, mediaPreviews]);
2364
2270
  }
2365
2271
  const FRAME_WIDTH = 1280;
2366
- const EMPTY_DOCUMENT = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"></head><body></body></html>";
2272
+ const EMPTY_DOCUMENT$1 = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"></head><body></body></html>";
2367
2273
  /**
2368
2274
  * A scaled-down, read-only view of a page as it will look on a desktop screen,
2369
2275
  * rendered into an isolated iframe so the site's styles and the admin panel's don't mix.
@@ -2398,7 +2304,7 @@ function PreviewFrame({ title, styles, children }) {
2398
2304
  className: "gfa-preview",
2399
2305
  children: [/* @__PURE__ */ jsx("iframe", {
2400
2306
  title,
2401
- srcDoc: EMPTY_DOCUMENT,
2307
+ srcDoc: EMPTY_DOCUMENT$1,
2402
2308
  onLoad: (event) => setDoc(event.currentTarget.contentDocument),
2403
2309
  style: {
2404
2310
  width: FRAME_WIDTH,
@@ -2461,7 +2367,7 @@ function restoreChange(content, subject) {
2461
2367
  case "entry": {
2462
2368
  const names = new Set(subject.collection.settings.fields.map((field) => field.name));
2463
2369
  const fields = Object.entries(subject.entry.content.fields).filter(([name]) => names.has(name));
2464
- return entryFileChange(subject.collection, subject.entry.slug, Object.fromEntries(fields));
2370
+ return entryFileChange$1(subject.collection, subject.entry.slug, Object.fromEntries(fields));
2465
2371
  }
2466
2372
  }
2467
2373
  }
@@ -2927,6 +2833,150 @@ function PublishFailure({ failure }) {
2927
2833
  });
2928
2834
  }
2929
2835
  //#endregion
2836
+ //#region src/upload.ts
2837
+ /** Photos are made no larger than this on their longest side, which is plenty for any screen. */
2838
+ const MAX_IMAGE_SIDE = 2400;
2839
+ var UploadError = class extends Error {
2840
+ problem;
2841
+ file;
2842
+ name = "UploadError";
2843
+ constructor(problem, file, message) {
2844
+ super(message);
2845
+ this.problem = problem;
2846
+ this.file = file;
2847
+ }
2848
+ };
2849
+ const REENCODED = {
2850
+ jpg: "image/jpeg",
2851
+ jpeg: "image/jpeg",
2852
+ png: "image/png",
2853
+ webp: "image/webp"
2854
+ };
2855
+ async function canvasBlob(canvas, type) {
2856
+ return new Promise((resolve) => canvas.toBlob(resolve, type, .85));
2857
+ }
2858
+ /**
2859
+ * Re-saves a photo: smaller if it's larger than any screen needs, and always
2860
+ * for JPEGs, which drops details hidden in the file such as where it was taken.
2861
+ * Returns `undefined` to keep the file as it is.
2862
+ */
2863
+ async function resized(file, extension) {
2864
+ const type = REENCODED[extension];
2865
+ if (!type || typeof createImageBitmap !== "function") return void 0;
2866
+ let bitmap;
2867
+ try {
2868
+ bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
2869
+ } catch {
2870
+ throw new UploadError("unreadable", file.name, `${file.name} couldn't be read as an image.`);
2871
+ }
2872
+ const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
2873
+ if (scale === 1 && type !== "image/jpeg") {
2874
+ bitmap.close();
2875
+ return;
2876
+ }
2877
+ const canvas = document.createElement("canvas");
2878
+ canvas.width = Math.round(bitmap.width * scale);
2879
+ canvas.height = Math.round(bitmap.height * scale);
2880
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
2881
+ bitmap.close();
2882
+ const blob = await canvasBlob(canvas, type);
2883
+ return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
2884
+ }
2885
+ const UNSAFE_SVG_ELEMENTS = [
2886
+ "script",
2887
+ "foreignObject",
2888
+ "iframe",
2889
+ "embed",
2890
+ "object",
2891
+ "audio",
2892
+ "video"
2893
+ ];
2894
+ /**
2895
+ * Removes anything from an SVG that could run code if someone opens the
2896
+ * file directly: scripts, embedded pages, event handlers, and javascript:, vbscript:
2897
+ * and data: links other than images.
2898
+ */
2899
+ function cleanSvg(text) {
2900
+ const doc = new DOMParser().parseFromString(text, "image/svg+xml");
2901
+ if (doc.querySelector("parsererror") || doc.documentElement.nodeName.toLowerCase() !== "svg") return void 0;
2902
+ for (const name of UNSAFE_SVG_ELEMENTS) for (const element of [...doc.getElementsByTagName(name)]) element.remove();
2903
+ for (const element of [doc.documentElement, ...doc.documentElement.querySelectorAll("*")]) for (const attribute of [...element.attributes]) {
2904
+ const value = attribute.value.trim().toLowerCase();
2905
+ if (attribute.name.toLowerCase().startsWith("on") || value.startsWith("javascript:") || value.startsWith("vbscript:") || value.startsWith("data:") && !value.startsWith("data:image/")) element.removeAttribute(attribute.name);
2906
+ }
2907
+ return new XMLSerializer().serializeToString(doc);
2908
+ }
2909
+ /** Checks a file chosen for upload and gets it ready: photos made web-sized, SVGs made safe. */
2910
+ async function prepareUpload(file) {
2911
+ const extension = extensionOf(file.name);
2912
+ if (!mediaKind(file.name)) throw new UploadError("type", file.name, `${file.name} isn't a kind of file the site can use.`);
2913
+ let blob = file;
2914
+ if (extension === "svg") {
2915
+ const cleaned = cleanSvg(await file.text());
2916
+ if (cleaned === void 0) throw new UploadError("unreadable", file.name, `${file.name} isn't a valid SVG image.`);
2917
+ blob = new Blob([cleaned], { type: "image/svg+xml" });
2918
+ } else blob = await resized(file, extension) ?? file;
2919
+ if (blob.size > 26214400) throw new UploadError("size", file.name, `${file.name} is ${blob.size} bytes, over the ${MAX_UPLOAD_BYTES}-byte limit.`);
2920
+ return {
2921
+ name: file.name,
2922
+ bytes: new Uint8Array(await blob.arrayBuffer()),
2923
+ type: blob.type || file.type
2924
+ };
2925
+ }
2926
+ //#endregion
2927
+ //#region src/stock-photos.ts
2928
+ /** The file types Openverse's downloads can come as that the media library takes. */
2929
+ const EXTENSIONS = {
2930
+ "image/jpeg": "jpg",
2931
+ "image/png": "png",
2932
+ "image/webp": "webp",
2933
+ "image/gif": "gif"
2934
+ };
2935
+ /**
2936
+ * Finds a photo for each of the AI's searches on Openverse, downloads it and
2937
+ * gets it ready to add to the media library under a name that isn't taken.
2938
+ * Searches past `MAX_STOCK_PHOTOS` find nothing.
2939
+ */
2940
+ async function findStockPhotos(queries, options) {
2941
+ const taken = [...options.taken];
2942
+ const photos = [];
2943
+ let problem;
2944
+ for (const query of queries.slice(0, MAX_STOCK_PHOTOS)) try {
2945
+ const photo = pickStockPhoto(await searchStockPhotos(query, options));
2946
+ if (!photo) continue;
2947
+ const blob = await downloadStockPhoto(photo, options);
2948
+ const extension = EXTENSIONS[blob.type.split(";")[0]?.trim().toLowerCase() ?? ""];
2949
+ if (!extension) continue;
2950
+ const name = uploadName(`${slugify(query).slice(0, 40) || "photo"}.${extension}`, taken);
2951
+ const prepared = await prepareUpload(new File([blob], name, { type: blob.type }));
2952
+ taken.push(name);
2953
+ photos.push({
2954
+ query,
2955
+ url: mediaUrl(`${MEDIA_DIR}/${name}`),
2956
+ bytes: prepared.bytes,
2957
+ credit: stockPhotoCredit(photo)
2958
+ });
2959
+ } catch (error) {
2960
+ if (options.signal?.aborted) throw error;
2961
+ if (error instanceof StockPhotoError) {
2962
+ problem = error;
2963
+ break;
2964
+ }
2965
+ }
2966
+ return {
2967
+ photos,
2968
+ missing: queries.length - photos.length,
2969
+ problem
2970
+ };
2971
+ }
2972
+ /** The media files for photos found, ready to publish with their credits (see `mediaFileChange()`). */
2973
+ function stockPhotoChanges(photos) {
2974
+ return photos.map((photo) => ({
2975
+ path: `${MEDIA_DIR}/${fileName(photo.url)}`,
2976
+ bytes: photo.bytes
2977
+ }));
2978
+ }
2979
+ //#endregion
2930
2980
  //#region src/ai-panel.tsx
2931
2981
  const usePuck$1 = createUsePuck();
2932
2982
  const claudeModelLabels = {
@@ -2934,7 +2984,7 @@ const claudeModelLabels = {
2934
2984
  "claude-sonnet-5-5": "ai.model.claude-sonnet-5-5",
2935
2985
  "claude-haiku-4-5": "ai.model.claude-haiku-4-5"
2936
2986
  };
2937
- function siteSummary(content, media) {
2987
+ function siteSummary(content, media, stockPhotos) {
2938
2988
  return {
2939
2989
  title: content.settings.title,
2940
2990
  description: content.settings.description,
@@ -2955,7 +3005,8 @@ function siteSummary(content, media) {
2955
3005
  }))
2956
3006
  })),
2957
3007
  images: siteImages(media),
2958
- contact: content.settings.contact
3008
+ contact: content.settings.contact,
3009
+ ...stockPhotos && { stockPhotos }
2959
3010
  };
2960
3011
  }
2961
3012
  function newId(type) {
@@ -2966,19 +3017,25 @@ function problemKey(error) {
2966
3017
  if (error instanceof SyntaxError) return "ai.error.answer";
2967
3018
  return "ai.error.service";
2968
3019
  }
2969
- function ServiceSettings({ providers, settings, onChange }) {
3020
+ function ServiceSettings({ providers, settings, onChange, siteAi }) {
2970
3021
  const t = useStrings();
2971
3022
  const info = PROVIDERS[settings.provider];
3023
+ const sitePays = siteAi?.services.includes(settings.provider) ?? false;
2972
3024
  const service = serviceName(settings.provider, t);
2973
3025
  const [models, setModels] = useState(info.models ?? []);
2974
3026
  const listId = useRef(`gfa-ai-models-${Math.random().toString(36).slice(2)}`).current;
2975
3027
  const { provider, apiKey, baseUrl } = settings;
3028
+ const server = sitePays ? siteAi?.server : void 0;
2976
3029
  useEffect(() => {
2977
3030
  let cancelled = false;
2978
3031
  const controller = new AbortController();
2979
3032
  setModels(PROVIDERS[provider].models ?? []);
2980
3033
  const timer = setTimeout(() => {
2981
- listModels({
3034
+ listModels(server ? {
3035
+ provider,
3036
+ server,
3037
+ model: ""
3038
+ } : {
2982
3039
  provider,
2983
3040
  apiKey,
2984
3041
  baseUrl,
@@ -2995,7 +3052,8 @@ function ServiceSettings({ providers, settings, onChange }) {
2995
3052
  }, [
2996
3053
  provider,
2997
3054
  apiKey,
2998
- baseUrl
3055
+ baseUrl,
3056
+ server
2999
3057
  ]);
3000
3058
  return /* @__PURE__ */ jsxs("div", {
3001
3059
  className: "gfa-form gfa-ai-settings",
@@ -3009,18 +3067,22 @@ function ServiceSettings({ providers, settings, onChange }) {
3009
3067
  onChange: (event) => onChange({ provider: event.target.value }),
3010
3068
  children: providers.map((id) => /* @__PURE__ */ jsxs("option", {
3011
3069
  value: id,
3012
- 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")}` : ""]
3013
3071
  }, id))
3014
3072
  })
3015
3073
  }),
3016
- 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, {
3017
3079
  label: t("ai.baseUrl"),
3018
3080
  hint: t("ai.baseUrlHint"),
3019
3081
  type: "url",
3020
3082
  value: settings.baseUrl ?? "",
3021
3083
  onChange: (baseUrl) => onChange({ baseUrl })
3022
3084
  }),
3023
- 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: [
3024
3086
  /* @__PURE__ */ jsx(TextField, {
3025
3087
  label: t("ai.key"),
3026
3088
  hint: t("ai.keyHint", { service }),
@@ -3089,12 +3151,26 @@ function serviceName(id, t) {
3089
3151
  /** The AI panel: describe what to write, and it's added to the page, replaces the selected block, or fills in the fields. */
3090
3152
  function AiPanel({ context }) {
3091
3153
  const t = useStrings();
3092
- const { config } = useAdmin();
3154
+ const { config, publish, mediaPreviews, account } = useAdmin();
3093
3155
  const { content, media } = useSiteContent();
3094
3156
  const getPuck = useGetPuck();
3095
3157
  const selected = usePuck$1((state) => state.selectedItem);
3096
- const providers = useMemo(() => allowedProviders(config.ai ? config.ai.providers : void 0), [config.ai]);
3097
- 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]);
3171
+ const [photosChosen, choosePhotos] = useStockPhotoChoice();
3172
+ const photosOffered = !(config.ai && config.ai.stockPhotos === false);
3173
+ const photosAllowed = photosOffered && photosChosen;
3098
3174
  const [instruction, setInstruction] = useState("");
3099
3175
  const [reply, setReply] = useState("");
3100
3176
  const [copied, setCopied] = useState(false);
@@ -3104,7 +3180,7 @@ function AiPanel({ context }) {
3104
3180
  const service = serviceName(settings.provider, t);
3105
3181
  const blockName = (block) => block ? getPuck().config.components[block.type]?.label ?? block.type : "";
3106
3182
  const itemName = inSentence(context.collection?.settings.entryName ?? "");
3107
- 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));
3108
3184
  const [settingsOpen, setSettingsOpen] = useState(!ready);
3109
3185
  useEffect(() => () => controller.current?.abort(), []);
3110
3186
  const taskFor = (text) => {
@@ -3181,11 +3257,50 @@ function AiPanel({ context }) {
3181
3257
  })
3182
3258
  });
3183
3259
  };
3260
+ /**
3261
+ * Finds the photos the AI asked for, adds them to the media library with
3262
+ * their credits in one publish, and puts their addresses in the answer.
3263
+ * Fields whose photo wasn't found are left empty.
3264
+ */
3265
+ const addPhotos = async (answer, signal) => {
3266
+ const queries = photosAllowed ? stockPhotoQueries(answer) : [];
3267
+ if (queries.length === 0) return { answer: withStockPhotos(answer, /* @__PURE__ */ new Map()) };
3268
+ setStatus({ type: "photos" });
3269
+ const found = await findStockPhotos(queries, {
3270
+ taken: media.map(fileName),
3271
+ signal
3272
+ });
3273
+ let added = found.photos;
3274
+ let failure;
3275
+ if (added.length > 0 && !signal.aborted) {
3276
+ for (const photo of added) mediaPreviews.remember(photo.url, photo.bytes);
3277
+ const credits = {
3278
+ ...content.credits,
3279
+ ...Object.fromEntries(added.map((photo) => [photo.url, photo.credit]))
3280
+ };
3281
+ const result = await publish([...stockPhotoChanges(added), mediaFileChange(credits)], t("ai.photos.message", { names: added.map((photo) => fileName(photo.url)).join(", ") }));
3282
+ if (!result.ok) {
3283
+ failure = result.error;
3284
+ added = [];
3285
+ }
3286
+ }
3287
+ const photos = new Map(added.map((photo) => [photo.query, photo.url]));
3288
+ const report = {
3289
+ added: added.map((photo) => fileName(photo.url)),
3290
+ missing: queries.length - added.length,
3291
+ problem: found.problem,
3292
+ failure
3293
+ };
3294
+ return {
3295
+ answer: withStockPhotos(answer, photos),
3296
+ photos: report
3297
+ };
3298
+ };
3184
3299
  const run = async () => {
3185
3300
  const text = instruction.trim();
3186
3301
  if (!text) return;
3187
3302
  const task = taskFor(text);
3188
- const request = buildRequest(task, getPuck().config, siteSummary(content, media));
3303
+ const request = buildRequest(task, getPuck().config, siteSummary(content, media, photosAllowed));
3189
3304
  if (settings.provider === "manual") {
3190
3305
  setReply("");
3191
3306
  setCopied(false);
@@ -3212,8 +3327,13 @@ function AiPanel({ context }) {
3212
3327
  })
3213
3328
  });
3214
3329
  if (current.signal.aborted) return;
3215
- apply(task, answer);
3216
- setStatus({ type: "done" });
3330
+ const filled = await addPhotos(answer, current.signal);
3331
+ if (current.signal.aborted) return;
3332
+ apply(task, filled.answer);
3333
+ setStatus({
3334
+ type: "done",
3335
+ photos: filled.photos
3336
+ });
3217
3337
  } catch (error) {
3218
3338
  if (current.signal.aborted) setStatus({ type: "idle" });
3219
3339
  else setStatus({
@@ -3222,19 +3342,29 @@ function AiPanel({ context }) {
3222
3342
  });
3223
3343
  }
3224
3344
  };
3225
- const applyReply = () => {
3345
+ const applyReply = async () => {
3226
3346
  if (status.type !== "manual") return;
3347
+ controller.current?.abort();
3348
+ const current = new AbortController();
3349
+ controller.current = current;
3350
+ const { task } = status;
3227
3351
  try {
3228
- apply(status.task, parseAnswer(reply));
3229
- setStatus({ type: "done" });
3230
- } catch (error) {
3352
+ const filled = await addPhotos(parseAnswer(reply), current.signal);
3353
+ if (current.signal.aborted) return;
3354
+ apply(task, filled.answer);
3231
3355
  setStatus({
3356
+ type: "done",
3357
+ photos: filled.photos
3358
+ });
3359
+ } catch (error) {
3360
+ if (current.signal.aborted) setStatus({ type: "idle" });
3361
+ else setStatus({
3232
3362
  type: "failed",
3233
3363
  error
3234
3364
  });
3235
3365
  }
3236
3366
  };
3237
- const working = status.type === "working";
3367
+ const working = status.type === "working" || status.type === "photos";
3238
3368
  return /* @__PURE__ */ jsxs("div", {
3239
3369
  className: "gfa-ai",
3240
3370
  children: [
@@ -3257,6 +3387,17 @@ function AiPanel({ context }) {
3257
3387
  onChange: (event) => setInstruction(event.target.value)
3258
3388
  })
3259
3389
  }),
3390
+ photosOffered && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("label", {
3391
+ className: "gfa-checkbox",
3392
+ children: [/* @__PURE__ */ jsx("input", {
3393
+ type: "checkbox",
3394
+ checked: photosChosen,
3395
+ onChange: (event) => choosePhotos(event.target.checked)
3396
+ }), t("ai.photos")]
3397
+ }), /* @__PURE__ */ jsx("p", {
3398
+ className: "gfa-hint",
3399
+ children: t("ai.photosHint")
3400
+ })] }),
3260
3401
  !ready && /* @__PURE__ */ jsx("p", {
3261
3402
  className: "gfa-hint",
3262
3403
  children: t("ai.needsKey", { service })
@@ -3273,16 +3414,21 @@ function AiPanel({ context }) {
3273
3414
  children: t("ai.stop")
3274
3415
  })]
3275
3416
  }),
3276
- working && /* @__PURE__ */ jsx("p", {
3417
+ status.type === "working" && /* @__PURE__ */ jsx("p", {
3277
3418
  className: "gfa-notice",
3278
3419
  role: "status",
3279
3420
  children: status.characters > 0 ? t("ai.progress", { count: String(status.characters) }) : t("ai.working")
3280
3421
  }),
3281
- status.type === "done" && /* @__PURE__ */ jsx("p", {
3282
- className: "gfa-notice gfa-notice-success",
3422
+ status.type === "photos" && /* @__PURE__ */ jsx("p", {
3423
+ className: "gfa-notice",
3283
3424
  role: "status",
3284
- children: t("ai.done")
3425
+ children: t("ai.photos.finding")
3285
3426
  }),
3427
+ status.type === "done" && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("p", {
3428
+ className: "gfa-notice gfa-notice-success",
3429
+ role: "status",
3430
+ children: [t("ai.done"), status.photos?.added.length ? ` ${t("ai.photos.added", { names: status.photos.added.join(", ") })}` : null]
3431
+ }), status.photos && /* @__PURE__ */ jsx(PhotoProblems, { report: status.photos })] }),
3286
3432
  status.type === "failed" && /* @__PURE__ */ jsx(ErrorMessage, {
3287
3433
  message: t(problemKey(status.error), { service }),
3288
3434
  error: status.error
@@ -3317,7 +3463,7 @@ function AiPanel({ context }) {
3317
3463
  /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
3318
3464
  variant: "primary",
3319
3465
  disabled: !reply.trim(),
3320
- onClick: applyReply,
3466
+ onClick: () => void applyReply(),
3321
3467
  children: t("ai.manual.use")
3322
3468
  }) })
3323
3469
  ]
@@ -3332,129 +3478,56 @@ function AiPanel({ context }) {
3332
3478
  onToggle: (event) => setSettingsOpen(event.currentTarget.open),
3333
3479
  children: [/* @__PURE__ */ jsx("summary", { children: t("ai.settings", { service }) }), /* @__PURE__ */ jsx(ServiceSettings, {
3334
3480
  providers,
3335
- settings,
3336
- onChange: updateSettings
3481
+ settings: chosen,
3482
+ onChange: updateSettings,
3483
+ ...siteAi && { siteAi }
3337
3484
  })]
3338
- })
3339
- ]
3340
- });
3341
- }
3342
- function SparkleIcon() {
3343
- return /* @__PURE__ */ jsxs("svg", {
3344
- width: "20",
3345
- height: "20",
3346
- viewBox: "0 0 24 24",
3347
- fill: "none",
3348
- stroke: "currentColor",
3349
- strokeWidth: "2",
3350
- "aria-hidden": "true",
3351
- children: [/* @__PURE__ */ jsx("path", {
3352
- d: "M12 3l1.9 5.6L19.5 10.5l-5.6 1.9L12 18l-1.9-5.6L4.5 10.5l5.6-1.9z",
3353
- strokeLinejoin: "round"
3354
- }), /* @__PURE__ */ jsx("path", {
3355
- d: "M19 15v4M17 17h4",
3356
- strokeLinecap: "round"
3357
- })]
3358
- });
3359
- }
3360
- /** The AI panel as a Puck plugin, shown in the editor's left-hand rail. */
3361
- function aiPlugin(context, label) {
3362
- return {
3363
- name: "ai",
3364
- label,
3365
- icon: /* @__PURE__ */ jsx(SparkleIcon, {}),
3366
- render: () => /* @__PURE__ */ jsx(AiPanel, { context })
3367
- };
3368
- }
3369
- //#endregion
3370
- //#region src/upload.ts
3371
- /** Photos are made no larger than this on their longest side, which is plenty for any screen. */
3372
- const MAX_IMAGE_SIDE = 2400;
3373
- var UploadError = class extends Error {
3374
- problem;
3375
- file;
3376
- name = "UploadError";
3377
- constructor(problem, file, message) {
3378
- super(message);
3379
- this.problem = problem;
3380
- this.file = file;
3381
- }
3382
- };
3383
- const REENCODED = {
3384
- jpg: "image/jpeg",
3385
- jpeg: "image/jpeg",
3386
- png: "image/png",
3387
- webp: "image/webp"
3388
- };
3389
- async function canvasBlob(canvas, type) {
3390
- return new Promise((resolve) => canvas.toBlob(resolve, type, .85));
3391
- }
3392
- /**
3393
- * Re-saves a photo: smaller if it's larger than any screen needs, and always
3394
- * for JPEGs, which drops details hidden in the file such as where it was taken.
3395
- * Returns `undefined` to keep the file as it is.
3396
- */
3397
- async function resized(file, extension) {
3398
- const type = REENCODED[extension];
3399
- if (!type || typeof createImageBitmap !== "function") return void 0;
3400
- let bitmap;
3401
- try {
3402
- bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
3403
- } catch {
3404
- throw new UploadError("unreadable", file.name, `${file.name} couldn't be read as an image.`);
3405
- }
3406
- const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
3407
- if (scale === 1 && type !== "image/jpeg") {
3408
- bitmap.close();
3409
- return;
3410
- }
3411
- const canvas = document.createElement("canvas");
3412
- canvas.width = Math.round(bitmap.width * scale);
3413
- canvas.height = Math.round(bitmap.height * scale);
3414
- canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
3415
- bitmap.close();
3416
- const blob = await canvasBlob(canvas, type);
3417
- return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
3485
+ })
3486
+ ]
3487
+ });
3418
3488
  }
3419
- const UNSAFE_SVG_ELEMENTS = [
3420
- "script",
3421
- "foreignObject",
3422
- "iframe",
3423
- "embed",
3424
- "object",
3425
- "audio",
3426
- "video"
3427
- ];
3428
- /**
3429
- * Removes anything from an SVG that could run code if someone opens the
3430
- * file directly: scripts, embedded pages, event handlers, and javascript:, vbscript:
3431
- * and data: links other than images.
3432
- */
3433
- function cleanSvg(text) {
3434
- const doc = new DOMParser().parseFromString(text, "image/svg+xml");
3435
- if (doc.querySelector("parsererror") || doc.documentElement.nodeName.toLowerCase() !== "svg") return void 0;
3436
- for (const name of UNSAFE_SVG_ELEMENTS) for (const element of [...doc.getElementsByTagName(name)]) element.remove();
3437
- for (const element of [doc.documentElement, ...doc.documentElement.querySelectorAll("*")]) for (const attribute of [...element.attributes]) {
3438
- const value = attribute.value.trim().toLowerCase();
3439
- if (attribute.name.toLowerCase().startsWith("on") || value.startsWith("javascript:") || value.startsWith("vbscript:") || value.startsWith("data:") && !value.startsWith("data:image/")) element.removeAttribute(attribute.name);
3440
- }
3441
- return new XMLSerializer().serializeToString(doc);
3489
+ /** Why some of the photos the AI asked for weren't added, if any weren't. */
3490
+ function PhotoProblems({ report }) {
3491
+ const t = useStrings();
3492
+ if (report.failure) return /* @__PURE__ */ jsx(ErrorMessage, {
3493
+ message: t("ai.photos.error.publish"),
3494
+ error: report.failure
3495
+ });
3496
+ if (report.problem) return /* @__PURE__ */ jsx(ErrorMessage, {
3497
+ message: t(`ai.photos.error.${report.problem.problem}`),
3498
+ error: report.problem
3499
+ });
3500
+ if (report.missing > 0) return /* @__PURE__ */ jsx("p", {
3501
+ className: "gfa-notice",
3502
+ children: t("ai.photos.missing", { count: String(report.missing) })
3503
+ });
3504
+ return null;
3442
3505
  }
3443
- /** Checks a file chosen for upload and gets it ready: photos made web-sized, SVGs made safe. */
3444
- async function prepareUpload(file) {
3445
- const extension = extensionOf(file.name);
3446
- if (!mediaKind(file.name)) throw new UploadError("type", file.name, `${file.name} isn't a kind of file the site can use.`);
3447
- let blob = file;
3448
- if (extension === "svg") {
3449
- const cleaned = cleanSvg(await file.text());
3450
- if (cleaned === void 0) throw new UploadError("unreadable", file.name, `${file.name} isn't a valid SVG image.`);
3451
- blob = new Blob([cleaned], { type: "image/svg+xml" });
3452
- } else blob = await resized(file, extension) ?? file;
3453
- if (blob.size > 26214400) throw new UploadError("size", file.name, `${file.name} is ${blob.size} bytes, over the ${MAX_UPLOAD_BYTES}-byte limit.`);
3506
+ function SparkleIcon() {
3507
+ return /* @__PURE__ */ jsxs("svg", {
3508
+ width: "20",
3509
+ height: "20",
3510
+ viewBox: "0 0 24 24",
3511
+ fill: "none",
3512
+ stroke: "currentColor",
3513
+ strokeWidth: "2",
3514
+ "aria-hidden": "true",
3515
+ children: [/* @__PURE__ */ jsx("path", {
3516
+ d: "M12 3l1.9 5.6L19.5 10.5l-5.6 1.9L12 18l-1.9-5.6L4.5 10.5l5.6-1.9z",
3517
+ strokeLinejoin: "round"
3518
+ }), /* @__PURE__ */ jsx("path", {
3519
+ d: "M19 15v4M17 17h4",
3520
+ strokeLinecap: "round"
3521
+ })]
3522
+ });
3523
+ }
3524
+ /** The AI panel as a Puck plugin, shown in the editor's left-hand rail. */
3525
+ function aiPlugin(context, label) {
3454
3526
  return {
3455
- name: file.name,
3456
- bytes: new Uint8Array(await blob.arrayBuffer()),
3457
- type: blob.type || file.type
3527
+ name: "ai",
3528
+ label,
3529
+ icon: /* @__PURE__ */ jsx(SparkleIcon, {}),
3530
+ render: () => /* @__PURE__ */ jsx(AiPanel, { context })
3458
3531
  };
3459
3532
  }
3460
3533
  //#endregion
@@ -3515,7 +3588,7 @@ const uploadProblems = {
3515
3588
  function useUploadMedia() {
3516
3589
  const t = useStrings();
3517
3590
  const { publish, mediaPreviews } = useAdmin();
3518
- const { media } = useSiteContent();
3591
+ const { content, media } = useSiteContent();
3519
3592
  return async (files, replace) => {
3520
3593
  const taken = media.map(fileName);
3521
3594
  const changes = [];
@@ -3543,7 +3616,8 @@ function useUploadMedia() {
3543
3616
  }
3544
3617
  for (const upload of uploads) mediaPreviews.remember(upload.url, upload.bytes);
3545
3618
  const names = changes.map((change) => fileName(change.path)).join(", ");
3546
- const result = await publish(changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }));
3619
+ const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
3620
+ const result = await publish(credits ? [...changes, credits] : changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }));
3547
3621
  if (!result.ok) return {
3548
3622
  ok: false,
3549
3623
  message: t(result.reason === "conflict" ? "publish.conflict" : "publish.error"),
@@ -3572,10 +3646,11 @@ function DeleteMediaDialog({ url, onClose }) {
3572
3646
  const path = mediaPath(url);
3573
3647
  if (!path) return;
3574
3648
  setBusy(true);
3649
+ const credits = withoutCredits(content.credits, [url]);
3575
3650
  const result = await publish([{
3576
3651
  path,
3577
3652
  delete: true
3578
- }], t("media.deleteMessage", { name }));
3653
+ }, ...credits ? [credits] : []], t("media.deleteMessage", { name }));
3579
3654
  setBusy(false);
3580
3655
  if (result.ok) onClose();
3581
3656
  else setFailure(result);
@@ -3602,6 +3677,21 @@ function DeleteMediaDialog({ url, onClose }) {
3602
3677
  ]
3603
3678
  });
3604
3679
  }
3680
+ /** Who made a photo and its license, which pages that use it show. */
3681
+ function MediaCredit({ credit }) {
3682
+ const t = useStrings();
3683
+ const text = [credit.creator, credit.license].filter(Boolean).join(" · ");
3684
+ return /* @__PURE__ */ jsx("span", {
3685
+ className: "gfa-media-credit",
3686
+ title: [credit.title, credit.source].filter(Boolean).join(" · "),
3687
+ children: credit.sourceUrl ? /* @__PURE__ */ jsx("a", {
3688
+ href: credit.sourceUrl,
3689
+ target: "_blank",
3690
+ rel: "noreferrer",
3691
+ children: t("media.credit", { credit: text })
3692
+ }) : t("media.credit", { credit: text })
3693
+ });
3694
+ }
3605
3695
  /** Lets files be dropped onto an area to upload them. */
3606
3696
  function DropZone({ onFiles, children }) {
3607
3697
  const t = useStrings();
@@ -3662,7 +3752,7 @@ function useUploads() {
3662
3752
  /** Every uploaded image and file, with buttons to upload, replace and delete them. */
3663
3753
  function MediaScreen() {
3664
3754
  const t = useStrings();
3665
- const { media } = useSiteContent();
3755
+ const { content, media } = useSiteContent();
3666
3756
  const { status, run } = useUploads();
3667
3757
  const [query, setQuery] = useState("");
3668
3758
  const [deleting, setDeleting] = useState(null);
@@ -3723,6 +3813,7 @@ function MediaScreen() {
3723
3813
  title: url,
3724
3814
  children: fileName(url)
3725
3815
  }),
3816
+ content.credits?.[url] && /* @__PURE__ */ jsx(MediaCredit, { credit: content.credits[url] }),
3726
3817
  /* @__PURE__ */ jsxs("div", {
3727
3818
  className: "gfa-media-actions",
3728
3819
  children: [
@@ -3961,7 +4052,8 @@ function TextFieldWithMedia({ field, value, onChange, readOnly, children }) {
3961
4052
  const ADMIN_PLACES = {
3962
4053
  settings: "#/settings/general",
3963
4054
  "contact-settings": `#/settings/general?section=${SETTINGS_SECTIONS.contact}`,
3964
- "forms-settings": `#/settings/general?section=${SETTINGS_SECTIONS.forms}`
4055
+ "forms-settings": `#/settings/general?section=${SETTINGS_SECTIONS.forms}`,
4056
+ "theme-settings": "#/settings/theme"
3965
4057
  };
3966
4058
  /** Where Puck remembers the sidebars' widths for every editor. */
3967
4059
  const PUCK_WIDTHS = "puck-sidebar-widths";
@@ -4225,10 +4317,11 @@ function inSentence(word) {
4225
4317
  /** The tabs shared by a collection's screens, above the screen itself. */
4226
4318
  function CollectionLayout({ collection, tab, children }) {
4227
4319
  const t = useStrings();
4320
+ const owner = useCanChangeSettings();
4228
4321
  const tabs = [
4229
4322
  ["entries", "collection.tab.entries"],
4230
- ...collection.settings.path ? [["template", "collection.tab.template"]] : [],
4231
- ["settings", "collection.tab.settings"]
4323
+ ...owner && collection.settings.path ? [["template", "collection.tab.template"]] : [],
4324
+ ...owner ? [["settings", "collection.tab.settings"]] : []
4232
4325
  ];
4233
4326
  return /* @__PURE__ */ jsxs("div", {
4234
4327
  className: "gfa-layout-screen",
@@ -4270,7 +4363,8 @@ function NewEntryDialog({ collection, onClose }) {
4270
4363
  const [failure, setFailure] = useState(null);
4271
4364
  const { settings } = collection;
4272
4365
  const entry = inSentence(settings.entryName);
4273
- const check = checkEntrySlug(settings.path ? slug : uniqueName(slugify(title), collection.entries.map((existing) => existing.slug), slugify(settings.entryName)), collection, allPages(content).map((page) => page.path));
4366
+ const name = settings.path ? slug : uniqueName(slugify(title), collection.entries.map((existing) => existing.slug), slugify(settings.entryName));
4367
+ const check = checkEntrySlug(name, collection, allPages(content).map((page) => page.path));
4274
4368
  const titleError = submitted && !title.trim() ? t("field.required") : void 0;
4275
4369
  const slugError = submitted && !check.ok ? t(nameErrors[check.problem]) : void 0;
4276
4370
  const onSubmit = async (event) => {
@@ -4278,7 +4372,7 @@ function NewEntryDialog({ collection, onClose }) {
4278
4372
  setSubmitted(true);
4279
4373
  if (!title.trim() || !check.ok) return;
4280
4374
  setBusy(true);
4281
- const result = await publish([entryFileChange(collection, check.slug, { title: title.trim() })], t("newEntry.message", {
4375
+ const result = await publish([entryFileChange$1(collection, check.slug, { title: title.trim() })], t("newEntry.message", {
4282
4376
  entry,
4283
4377
  title: title.trim()
4284
4378
  }));
@@ -4587,7 +4681,7 @@ function TemplateScreen({ collection }) {
4587
4681
  collection,
4588
4682
  notice: /* @__PURE__ */ jsx(TemplateHelp, { collection }),
4589
4683
  toChanges: (data) => ({
4590
- changes: [collectionFileChange(collection.id, {
4684
+ changes: [collectionFileChange$1(collection.id, {
4591
4685
  ...settings,
4592
4686
  template: data
4593
4687
  })],
@@ -5287,7 +5381,7 @@ function NewCollectionDialog({ onClose }) {
5287
5381
  kind,
5288
5382
  blocks: Object.keys(config.blocks)
5289
5383
  });
5290
- const changes = [collectionFileChange(id, settings)];
5384
+ const changes = [collectionFileChange$1(id, settings)];
5291
5385
  if (setsTimeZone) changes.push(siteSettingsFileChange({
5292
5386
  ...content.settings,
5293
5387
  timeZone: ownTimeZone
@@ -5398,106 +5492,343 @@ function NewCollectionDialog({ onClose }) {
5398
5492
  function CollectionsScreen() {
5399
5493
  const t = useStrings();
5400
5494
  const { content } = useSiteContent();
5495
+ const owner = useCanChangeSettings();
5401
5496
  const [creating, setCreating] = useState(false);
5402
5497
  return /* @__PURE__ */ jsxs("div", {
5403
5498
  className: "gfa-screen",
5404
5499
  children: [
5405
5500
  /* @__PURE__ */ jsxs("div", {
5406
5501
  className: "gfa-screen-header",
5407
- children: [/* @__PURE__ */ jsx("h1", { children: t("collections.title") }), /* @__PURE__ */ jsx(Button, {
5502
+ children: [/* @__PURE__ */ jsx("h1", { children: t("collections.title") }), owner && /* @__PURE__ */ jsx(Button, {
5408
5503
  variant: "primary",
5409
5504
  onClick: () => setCreating(true),
5410
5505
  children: t("collections.new")
5411
5506
  })]
5412
- }),
5413
- /* @__PURE__ */ jsx("p", {
5414
- className: "gfa-hint",
5415
- children: t("collections.intro")
5416
- }),
5417
- content.collections.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("collections.empty") }) : /* @__PURE__ */ jsxs("table", {
5418
- className: "gfa-table",
5419
- children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
5420
- /* @__PURE__ */ jsx("th", { children: t("collections.column.name") }),
5421
- /* @__PURE__ */ jsx("th", { children: t("collections.column.count") }),
5422
- /* @__PURE__ */ jsx("th", { children: t("collections.column.address") })
5423
- ] }) }), /* @__PURE__ */ jsx("tbody", { children: content.collections.map((collection) => /* @__PURE__ */ jsxs("tr", { children: [
5424
- /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx("a", {
5425
- href: collectionHref(collection.id),
5426
- className: "gfa-link-strong",
5427
- children: collection.settings.name
5428
- }) }),
5429
- /* @__PURE__ */ jsx("td", { children: collection.entries.length }),
5430
- /* @__PURE__ */ jsx("td", { children: collection.settings.path ? /* @__PURE__ */ jsx("code", { children: collection.settings.path }) : /* @__PURE__ */ jsx("span", {
5431
- className: "gfa-hint",
5432
- children: t("collections.noPages")
5433
- }) })
5434
- ] }, collection.id)) })]
5435
- }),
5436
- creating && /* @__PURE__ */ jsx(NewCollectionDialog, { onClose: () => setCreating(false) })
5507
+ }),
5508
+ /* @__PURE__ */ jsx("p", {
5509
+ className: "gfa-hint",
5510
+ children: t("collections.intro")
5511
+ }),
5512
+ content.collections.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("collections.empty") }) : /* @__PURE__ */ jsxs("table", {
5513
+ className: "gfa-table",
5514
+ children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
5515
+ /* @__PURE__ */ jsx("th", { children: t("collections.column.name") }),
5516
+ /* @__PURE__ */ jsx("th", { children: t("collections.column.count") }),
5517
+ /* @__PURE__ */ jsx("th", { children: t("collections.column.address") })
5518
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: content.collections.map((collection) => /* @__PURE__ */ jsxs("tr", { children: [
5519
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx("a", {
5520
+ href: collectionHref(collection.id),
5521
+ className: "gfa-link-strong",
5522
+ children: collection.settings.name
5523
+ }) }),
5524
+ /* @__PURE__ */ jsx("td", { children: collection.entries.length }),
5525
+ /* @__PURE__ */ jsx("td", { children: collection.settings.path ? /* @__PURE__ */ jsx("code", { children: collection.settings.path }) : /* @__PURE__ */ jsx("span", {
5526
+ className: "gfa-hint",
5527
+ children: t("collections.noPages")
5528
+ }) })
5529
+ ] }, collection.id)) })]
5530
+ }),
5531
+ creating && /* @__PURE__ */ jsx(NewCollectionDialog, { onClose: () => setCreating(false) })
5532
+ ]
5533
+ });
5534
+ }
5535
+ //#endregion
5536
+ //#region src/demo.ts
5537
+ const DATABASE = "goodfellow-demo";
5538
+ const TABLE = "changes";
5539
+ function request(pending) {
5540
+ return new Promise((resolve, reject) => {
5541
+ pending.onsuccess = () => resolve(pending.result);
5542
+ pending.onerror = () => reject(pending.error);
5543
+ });
5544
+ }
5545
+ /**
5546
+ * Keeps a demo visitor's changes in the browser's IndexedDB, which can hold
5547
+ * uploads too large for local storage. `key` tells apart demos served from the
5548
+ * same address, such as two GitHub Pages sites of one account.
5549
+ */
5550
+ function browserDemoStorage(key) {
5551
+ let database;
5552
+ const open = () => {
5553
+ database ??= new Promise((resolve, reject) => {
5554
+ const opening = indexedDB.open(DATABASE, 1);
5555
+ opening.onupgradeneeded = () => opening.result.createObjectStore(TABLE);
5556
+ opening.onsuccess = () => resolve(opening.result);
5557
+ opening.onerror = () => reject(opening.error);
5558
+ });
5559
+ return database;
5560
+ };
5561
+ const table = async (mode) => (await open()).transaction(TABLE, mode).objectStore(TABLE);
5562
+ return {
5563
+ load: async () => await request((await table("readonly")).get(key)),
5564
+ save: async (changes) => {
5565
+ await request((await table("readwrite")).put(changes, key));
5566
+ },
5567
+ clear: async () => {
5568
+ await request((await table("readwrite")).delete(key));
5569
+ }
5570
+ };
5571
+ }
5572
+ /**
5573
+ * The store a demo admin panel uses: the site's content, read from `base` or
5574
+ * from the copy the build put beside the admin panel, with the visitor's
5575
+ * changes kept in their browser.
5576
+ */
5577
+ function createDemoStore(siteUrl, base) {
5578
+ const root = trimChars(siteUrl, "/", { start: false });
5579
+ const content = base ?? demoContentStore(async () => {
5580
+ const response = await fetch(`${root}/${DEMO_CONTENT_PATH}`);
5581
+ if (!response.ok) throw new Error(`The demo's content couldn't be loaded (${response.status}).`);
5582
+ return parseDemoContent(await response.text());
5583
+ }, async (path) => {
5584
+ const response = await fetch(`${root}${mediaUrl(path)}`);
5585
+ return response.ok ? new Uint8Array(await response.arrayBuffer()) : void 0;
5586
+ });
5587
+ return demoStore(content, browserDemoStorage(new URL(`${root}/`, location.href).href));
5588
+ }
5589
+ //#endregion
5590
+ //#region src/sign-in.tsx
5591
+ function errorMessage(error) {
5592
+ if (error instanceof SignInError) return `signIn.error.${error.problem}`;
5593
+ return "signIn.error.other";
5594
+ }
5595
+ /** Problems with signing in by going to the host, which signing in with a token gets around. */
5596
+ const REDIRECT_PROBLEMS = /* @__PURE__ */ new Set([
5597
+ "not-installed",
5598
+ "no-address",
5599
+ "wrong-address",
5600
+ "local-address",
5601
+ "unavailable"
5602
+ ]);
5603
+ /**
5604
+ * Explains why signing in didn't work. Where Goodfellow isn't installed yet,
5605
+ * `onInstall` offers to install it, which signs the person in too; otherwise
5606
+ * it links to where it's fixed.
5607
+ */
5608
+ function SignInProblem({ error, host, expired, onInstall }) {
5609
+ const t = useStrings();
5610
+ const link = error instanceof SignInError ? error.link : void 0;
5611
+ const installable = onInstall && error instanceof SignInError && error.problem === "not-installed";
5612
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ErrorMessage, {
5613
+ message: expired && error instanceof SignInError && error.problem === "invalid" ? t("signIn.error.expired") : t(errorMessage(error), { host }),
5614
+ error
5615
+ }), installable ? /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
5616
+ variant: "primary",
5617
+ onClick: onInstall,
5618
+ children: t("signIn.install", { host })
5619
+ }) }) : link && /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx("a", {
5620
+ href: link,
5621
+ target: "_blank",
5622
+ rel: "noreferrer",
5623
+ children: t("signIn.install", { host })
5624
+ }) })] });
5625
+ }
5626
+ function SignInScreen({ host, error, onSignedIn }) {
5627
+ const t = useStrings();
5628
+ const [token, setToken] = useState("");
5629
+ const [remember, setRemember] = useState(false);
5630
+ const [busy, setBusy] = useState(false);
5631
+ const [failure, setFailure] = useState(error);
5632
+ const values = { host: host.name };
5633
+ const redirectFailed = failure instanceof SignInError && REDIRECT_PROBLEMS.has(failure.problem);
5634
+ const redirect = async (options) => {
5635
+ setBusy(true);
5636
+ setFailure(void 0);
5637
+ try {
5638
+ await host.startRedirect(remember, options);
5639
+ } catch (caught) {
5640
+ setFailure(caught);
5641
+ setBusy(false);
5642
+ }
5643
+ };
5644
+ const onSubmit = async (event) => {
5645
+ event.preventDefault();
5646
+ setBusy(true);
5647
+ setFailure(void 0);
5648
+ try {
5649
+ onSignedIn(await host.signInWithToken(token, remember));
5650
+ } catch (caught) {
5651
+ setFailure(caught);
5652
+ setBusy(false);
5653
+ }
5654
+ };
5655
+ const remembered = /* @__PURE__ */ jsxs("label", {
5656
+ className: "gfa-checkbox",
5657
+ children: [/* @__PURE__ */ jsx("input", {
5658
+ type: "checkbox",
5659
+ checked: remember,
5660
+ onChange: (event) => setRemember(event.target.checked)
5661
+ }), /* @__PURE__ */ jsxs("span", { children: [t("signIn.remember"), /* @__PURE__ */ jsx("span", {
5662
+ className: "gfa-hint gfa-block",
5663
+ children: t("signIn.rememberHint")
5664
+ })] })]
5665
+ });
5666
+ return /* @__PURE__ */ jsxs("div", {
5667
+ className: "gfa-sign-in",
5668
+ children: [
5669
+ /* @__PURE__ */ jsx("h1", { children: t("signIn.title") }),
5670
+ /* @__PURE__ */ jsx("p", { children: t("signIn.intro", values) }),
5671
+ failure !== void 0 && /* @__PURE__ */ jsx(SignInProblem, {
5672
+ error: failure,
5673
+ host: host.name,
5674
+ expired: error === failure,
5675
+ onInstall: () => void redirect({ install: true })
5676
+ }),
5677
+ host.canRedirect && /* @__PURE__ */ jsxs("div", {
5678
+ className: "gfa-form",
5679
+ children: [remembered, /* @__PURE__ */ jsx(Button, {
5680
+ variant: "primary",
5681
+ disabled: busy,
5682
+ onClick: () => void redirect(),
5683
+ children: t("signIn.redirect", values)
5684
+ })]
5685
+ }),
5686
+ host.tokenLinks && host.tokenLinks.length > 0 && /* @__PURE__ */ jsxs("details", {
5687
+ className: "gfa-sign-in-token",
5688
+ open: !host.canRedirect || redirectFailed,
5689
+ children: [/* @__PURE__ */ jsx("summary", { children: t(host.canRedirect ? "signIn.orToken" : "signIn.token.title") }), /* @__PURE__ */ jsxs("form", {
5690
+ className: "gfa-form",
5691
+ onSubmit,
5692
+ children: [
5693
+ /* @__PURE__ */ jsxs("ol", {
5694
+ className: "gfa-steps",
5695
+ children: [/* @__PURE__ */ jsxs("li", { children: [
5696
+ /* @__PURE__ */ jsx("p", { children: t("signIn.token.step1", values) }),
5697
+ /* @__PURE__ */ jsx("ul", {
5698
+ className: "gfa-token-links",
5699
+ children: host.tokenLinks.map((link) => /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx("a", {
5700
+ href: link.url,
5701
+ target: "_blank",
5702
+ rel: "noreferrer",
5703
+ children: t(link.label, values)
5704
+ }), link.hint && /* @__PURE__ */ jsx("p", {
5705
+ className: "gfa-hint",
5706
+ children: t(link.hint, values)
5707
+ })] }, link.url))
5708
+ }),
5709
+ /* @__PURE__ */ jsx("p", {
5710
+ className: "gfa-hint",
5711
+ children: t("signIn.token.chooseSite", values)
5712
+ })
5713
+ ] }), /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx("p", { children: t("signIn.token.step2") }), /* @__PURE__ */ jsx(TextField, {
5714
+ label: t("signIn.token.label"),
5715
+ type: "password",
5716
+ autoComplete: "off",
5717
+ spellCheck: false,
5718
+ value: token,
5719
+ onChange: setToken
5720
+ })] })]
5721
+ }),
5722
+ !host.canRedirect && remembered,
5723
+ /* @__PURE__ */ jsx(Button, {
5724
+ type: "submit",
5725
+ variant: "primary",
5726
+ disabled: busy || !token.trim(),
5727
+ children: busy ? t("signIn.checking") : t("signIn.submit")
5728
+ })
5729
+ ]
5730
+ })]
5731
+ })
5437
5732
  ]
5438
5733
  });
5439
5734
  }
5440
- //#endregion
5441
- //#region src/demo.ts
5442
- const DATABASE = "goodfellow-demo";
5443
- const TABLE = "changes";
5444
- function request(pending) {
5445
- return new Promise((resolve, reject) => {
5446
- pending.onsuccess = () => resolve(pending.result);
5447
- pending.onerror = () => reject(pending.error);
5448
- });
5449
- }
5450
5735
  /**
5451
- * Keeps a demo visitor's changes in the browser's IndexedDB, which can hold
5452
- * uploads too large for local storage. `key` tells apart demos served from the
5453
- * same address, such as two GitHub Pages sites of one account.
5736
+ * Shows the sign-in screen until someone is signed in to the git host, then
5737
+ * renders its children with the connected backend.
5454
5738
  */
5455
- function browserDemoStorage(key) {
5456
- let database;
5457
- const open = () => {
5458
- database ??= new Promise((resolve, reject) => {
5459
- const opening = indexedDB.open(DATABASE, 1);
5460
- opening.onupgradeneeded = () => opening.result.createObjectStore(TABLE);
5461
- opening.onsuccess = () => resolve(opening.result);
5462
- opening.onerror = () => reject(opening.error);
5739
+ function SignInGate({ host, children }) {
5740
+ const t = useStrings();
5741
+ const [state, setState] = useState({ status: "checking" });
5742
+ useEffect(() => {
5743
+ let cancelled = false;
5744
+ host.restore().then((backend) => !cancelled && setState(backend ? {
5745
+ status: "signed-in",
5746
+ backend
5747
+ } : { status: "signed-out" }), (error) => !cancelled && setState({
5748
+ status: "signed-out",
5749
+ error
5750
+ }));
5751
+ return () => {
5752
+ cancelled = true;
5753
+ };
5754
+ }, [host]);
5755
+ if (state.status === "checking") return /* @__PURE__ */ jsx("p", {
5756
+ className: "gfa-screen",
5757
+ children: t("signIn.checking")
5758
+ });
5759
+ if (state.status === "signed-out") return /* @__PURE__ */ jsx(SignInScreen, {
5760
+ host,
5761
+ error: state.error,
5762
+ onSignedIn: (backend) => setState({
5763
+ status: "signed-in",
5764
+ backend
5765
+ })
5766
+ });
5767
+ const { backend } = state;
5768
+ return children(backend, (error) => {
5769
+ backend.signOut();
5770
+ setState({
5771
+ status: "signed-out",
5772
+ error
5463
5773
  });
5464
- return database;
5465
- };
5466
- const table = async (mode) => (await open()).transaction(TABLE, mode).objectStore(TABLE);
5467
- return {
5468
- load: async () => await request((await table("readonly")).get(key)),
5469
- save: async (changes) => {
5470
- await request((await table("readwrite")).put(changes, key));
5471
- },
5472
- clear: async () => {
5473
- await request((await table("readwrite")).delete(key));
5474
- }
5475
- };
5476
- }
5477
- /**
5478
- * The store a demo admin panel uses: the site's content, read from `base` or
5479
- * from the copy the build put beside the admin panel, with the visitor's
5480
- * changes kept in their browser.
5481
- */
5482
- function createDemoStore(siteUrl, base) {
5483
- const root = trimChars(siteUrl, "/", { start: false });
5484
- const content = base ?? demoContentStore(async () => {
5485
- const response = await fetch(`${root}/${DEMO_CONTENT_PATH}`);
5486
- if (!response.ok) throw new Error(`The demo's content couldn't be loaded (${response.status}).`);
5487
- return parseDemoContent(await response.text());
5488
- }, async (path) => {
5489
- const response = await fetch(`${root}${mediaUrl(path)}`);
5490
- return response.ok ? new Uint8Array(await response.arrayBuffer()) : void 0;
5491
5774
  });
5492
- return demoStore(content, browserDemoStorage(new URL(`${root}/`, location.href).href));
5493
5775
  }
5494
5776
  //#endregion
5495
5777
  //#region src/owner-token.tsx
5496
5778
  /**
5497
- * Asks for the owner token that hosts such as GitHub need for changing editors
5498
- * and Pages settings, which signing in can't. It's used for this tab only.
5779
+ * Asks an owner to sign in for changing editors and Pages settings, which
5780
+ * everyday sign-in can't, where the host can, with the owner token form below.
5781
+ * It's used for this tab only.
5499
5782
  */
5500
5783
  function OwnerTokenForm({ onReady }) {
5784
+ const t = useStrings();
5785
+ const { ownerAccess, account } = useAdmin();
5786
+ const host = account?.hostName ?? "";
5787
+ const [busy, setBusy] = useState(false);
5788
+ const [error, setError] = useState(ownerAccess?.problem);
5789
+ if (!ownerAccess) return null;
5790
+ if (!ownerAccess.startRedirect) return /* @__PURE__ */ jsx(OwnerTokenFields, { onReady });
5791
+ const signIn = async (options) => {
5792
+ setBusy(true);
5793
+ setError(void 0);
5794
+ try {
5795
+ await ownerAccess.startRedirect?.(options);
5796
+ } catch (caught) {
5797
+ setError(caught);
5798
+ setBusy(false);
5799
+ }
5800
+ };
5801
+ return /* @__PURE__ */ jsxs("section", {
5802
+ className: "gfa-form gfa-owner-token",
5803
+ children: [
5804
+ /* @__PURE__ */ jsx("h2", {
5805
+ className: "gfa-section-title",
5806
+ children: t("owner.signIn.title")
5807
+ }),
5808
+ /* @__PURE__ */ jsx("p", { children: t("owner.signIn.intro", { host }) }),
5809
+ error !== void 0 && /* @__PURE__ */ jsx(SignInProblem, {
5810
+ error,
5811
+ host,
5812
+ onInstall: () => void signIn({ install: true })
5813
+ }),
5814
+ /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
5815
+ variant: "primary",
5816
+ disabled: busy,
5817
+ onClick: () => void signIn(),
5818
+ children: t("owner.signIn.button", { host })
5819
+ }) }),
5820
+ /* @__PURE__ */ jsxs("details", {
5821
+ className: "gfa-sign-in-token",
5822
+ children: [/* @__PURE__ */ jsx("summary", { children: t("owner.signIn.orToken") }), /* @__PURE__ */ jsx(OwnerTokenFields, {
5823
+ onReady,
5824
+ heading: false
5825
+ })]
5826
+ })
5827
+ ]
5828
+ });
5829
+ }
5830
+ /** The owner token form: where to create one, and a field to paste it into. */
5831
+ function OwnerTokenFields({ onReady, heading = true }) {
5501
5832
  const t = useStrings();
5502
5833
  const { ownerAccess, account } = useAdmin();
5503
5834
  const host = account?.hostName ?? "";
@@ -5519,9 +5850,9 @@ function OwnerTokenForm({ onReady }) {
5519
5850
  }
5520
5851
  };
5521
5852
  return /* @__PURE__ */ jsxs("section", {
5522
- className: "gfa-form gfa-owner-token",
5853
+ className: heading ? "gfa-form gfa-owner-token" : "gfa-form",
5523
5854
  children: [
5524
- /* @__PURE__ */ jsx("h2", {
5855
+ heading && /* @__PURE__ */ jsx("h2", {
5525
5856
  className: "gfa-section-title",
5526
5857
  children: t("owner.token.title")
5527
5858
  }),
@@ -5583,68 +5914,6 @@ function OwnerTokenActive({ onForget }) {
5583
5914
  });
5584
5915
  }
5585
5916
  //#endregion
5586
- //#region src/theme.ts
5587
- const KEY = "goodfellow-admin-theme";
5588
- const listeners = /* @__PURE__ */ new Set();
5589
- function read() {
5590
- try {
5591
- const saved = localStorage.getItem(KEY);
5592
- return saved === "light" || saved === "dark" ? saved : "system";
5593
- } catch {
5594
- return "system";
5595
- }
5596
- }
5597
- let current;
5598
- function choice() {
5599
- current ??= read();
5600
- return current;
5601
- }
5602
- /** Shows the choice: the stylesheet's colors follow `data-gfa-theme`, or the computer's setting without it. */
5603
- function apply(next) {
5604
- const root = document.documentElement;
5605
- if (next === "system") delete root.dataset.gfaTheme;
5606
- else root.dataset.gfaTheme = next;
5607
- }
5608
- function setThemeChoice(next) {
5609
- current = next;
5610
- try {
5611
- if (next === "system") localStorage.removeItem(KEY);
5612
- else localStorage.setItem(KEY, next);
5613
- } catch {}
5614
- apply(next);
5615
- for (const listener of listeners) listener();
5616
- }
5617
- function subscribe(listener) {
5618
- listeners.add(listener);
5619
- const media = matchMedia("(prefers-color-scheme: dark)");
5620
- media.addEventListener("change", listener);
5621
- return () => {
5622
- listeners.delete(listener);
5623
- media.removeEventListener("change", listener);
5624
- };
5625
- }
5626
- /** The choice, kept in this browser. */
5627
- function useThemeChoice() {
5628
- return useSyncExternalStore(subscribe, choice, () => "system");
5629
- }
5630
- /** Applies the choice to the page while the admin panel is open. Used once, by the admin panel itself. */
5631
- function useApplyThemeChoice() {
5632
- const value = useThemeChoice();
5633
- useEffect(() => {
5634
- apply(value);
5635
- return () => {
5636
- delete document.documentElement.dataset.gfaTheme;
5637
- };
5638
- }, [value]);
5639
- }
5640
- /** Whether the admin panel is dark at the moment, by choice or by the computer's setting. */
5641
- function useDarkMode() {
5642
- return useSyncExternalStore(subscribe, () => {
5643
- const value = choice();
5644
- return value === "dark" || value === "system" && matchMedia("(prefers-color-scheme: dark)").matches;
5645
- }, () => false);
5646
- }
5647
- //#endregion
5648
5917
  //#region src/code-editor.tsx
5649
5918
  /** CodeMirror and its languages, loaded the first time a code editor is shown. */
5650
5919
  async function loadCodeMirror(language) {
@@ -5933,7 +6202,7 @@ function FontPicker({ label, value, error, onChange }) {
5933
6202
  setQuery("");
5934
6203
  };
5935
6204
  const pick = (option) => {
5936
- onChange(option ? option[0] : "");
6205
+ onChange(option ? option[0] : "", option?.[1]);
5937
6206
  close();
5938
6207
  };
5939
6208
  const openList = () => {
@@ -6502,7 +6771,10 @@ function headCodeProblem(t, head) {
6502
6771
  /** Turns the form's values into settings: empty optional fields are left out rather than saved as "". */
6503
6772
  function cleanSettings(draft) {
6504
6773
  const optional = (value) => value?.trim() ? value.trim() : void 0;
6505
- const colors = Object.fromEntries(Object.entries(draft.theme.colors).filter(([, value]) => value?.trim()));
6774
+ const filled = (colors) => Object.fromEntries(Object.entries(colors ?? {}).filter(([, value]) => value?.trim()));
6775
+ const colors = filled(draft.theme.colors);
6776
+ const darkColors = filled(draft.theme.darkColors);
6777
+ const darkLogo = optional(draft.logo?.darkSrc);
6506
6778
  return {
6507
6779
  ...draft,
6508
6780
  url: optional(draft.url),
@@ -6512,20 +6784,40 @@ function cleanSettings(draft) {
6512
6784
  socialImage: optional(draft.socialImage),
6513
6785
  logo: draft.logo?.src.trim() ? {
6514
6786
  src: draft.logo.src.trim(),
6515
- alt: draft.logo.alt
6787
+ alt: draft.logo.alt,
6788
+ ...darkLogo && { darkSrc: darkLogo }
6516
6789
  } : void 0,
6517
6790
  contact: cleanContact(draft.contact),
6518
6791
  forms: cleanForms(draft.forms),
6519
6792
  theme: {
6520
6793
  colors,
6521
- fonts: {
6522
- heading: optional(draft.theme.fonts.heading),
6523
- body: optional(draft.theme.fonts.body)
6524
- },
6525
- radius: optional(draft.theme.radius)
6794
+ fonts: cleanFonts(draft.theme.fonts),
6795
+ radius: optional(draft.theme.radius),
6796
+ darkMode: draft.theme.darkMode || void 0,
6797
+ darkColors: Object.keys(darkColors).length > 0 ? darkColors : void 0,
6798
+ code: cleanCode(draft.theme.code)
6526
6799
  }
6527
6800
  };
6528
6801
  }
6802
+ /** A font's kind is kept only with the font, which is all it's about. */
6803
+ function cleanFonts(fonts) {
6804
+ const heading = fonts.heading?.trim() || void 0;
6805
+ const body = fonts.body?.trim() || void 0;
6806
+ return {
6807
+ heading,
6808
+ ...heading && fonts.headingCategory && { headingCategory: fonts.headingCategory },
6809
+ body,
6810
+ ...body && fonts.bodyCategory && { bodyCategory: fonts.bodyCategory }
6811
+ };
6812
+ }
6813
+ function cleanCode(code) {
6814
+ const cleaned = {
6815
+ ...code?.light && { light: code.light },
6816
+ ...code?.dark && { dark: code.dark },
6817
+ ...code?.background === "site" && { background: "site" }
6818
+ };
6819
+ return Object.keys(cleaned).length > 0 ? cleaned : void 0;
6820
+ }
6529
6821
  function cleanContact(contact) {
6530
6822
  const entries = Object.entries(contact ?? {}).flatMap(([key, value]) => value?.trim() ? [[key, value.trim()]] : []);
6531
6823
  return entries.length > 0 ? Object.fromEntries(entries) : void 0;
@@ -6743,6 +7035,7 @@ function GeneralTab({ draft, errors, formsError, onChange }) {
6743
7035
  onChange: (src) => onChange({
6744
7036
  ...draft,
6745
7037
  logo: {
7038
+ ...draft.logo,
6746
7039
  alt: draft.logo?.alt ?? "",
6747
7040
  src
6748
7041
  }
@@ -6754,11 +7047,25 @@ function GeneralTab({ draft, errors, formsError, onChange }) {
6754
7047
  onChange: (alt) => onChange({
6755
7048
  ...draft,
6756
7049
  logo: {
7050
+ ...draft.logo,
6757
7051
  src: draft.logo?.src ?? "",
6758
7052
  alt
6759
7053
  }
6760
7054
  })
6761
7055
  }),
7056
+ draft.theme.darkMode && /* @__PURE__ */ jsx(MediaField, {
7057
+ label: t("general.logoDark"),
7058
+ hint: t("general.logoDarkHint"),
7059
+ value: draft.logo?.darkSrc ?? "",
7060
+ onChange: (darkSrc) => onChange({
7061
+ ...draft,
7062
+ logo: {
7063
+ src: draft.logo?.src ?? "",
7064
+ alt: draft.logo?.alt ?? "",
7065
+ darkSrc
7066
+ }
7067
+ })
7068
+ }),
6762
7069
  /* @__PURE__ */ jsx(MediaField, {
6763
7070
  label: t("general.favicon"),
6764
7071
  ...text("favicon")
@@ -6829,7 +7136,37 @@ function GeneralTab({ draft, errors, formsError, onChange }) {
6829
7136
  });
6830
7137
  }
6831
7138
  const HEX = /^#[0-9a-f]{6}$/i;
6832
- function ThemeTab({ draft, errors, onChange }) {
7139
+ /** One set of the theme's colors: the light ones, or dark mode's. */
7140
+ function ColorGrid({ colors, defaults, errors, errorPrefix, onChange }) {
7141
+ const t = useStrings();
7142
+ return /* @__PURE__ */ jsx("div", {
7143
+ className: "gfa-color-grid",
7144
+ children: THEME_COLORS.map((name) => {
7145
+ const value = colors[name] ?? "";
7146
+ const shown = value || defaults[name];
7147
+ return /* @__PURE__ */ jsx(Field, {
7148
+ label: t(`theme.color.${name}`),
7149
+ error: errors[`${errorPrefix}.${name}`],
7150
+ children: (props) => /* @__PURE__ */ jsxs("div", {
7151
+ className: "gfa-color-input",
7152
+ children: [/* @__PURE__ */ jsx("input", {
7153
+ type: "color",
7154
+ "aria-label": t("theme.pickColor", { name: t(`theme.color.${name}`) }),
7155
+ value: HEX.test(shown) ? shown : "#000000",
7156
+ onChange: (event) => onChange(name, event.target.value)
7157
+ }), /* @__PURE__ */ jsx("input", {
7158
+ ...props,
7159
+ className: "gfa-input",
7160
+ value,
7161
+ placeholder: `${t("theme.default")}: ${defaults[name]}`,
7162
+ onChange: (event) => onChange(name, event.target.value)
7163
+ })]
7164
+ })
7165
+ }, name);
7166
+ })
7167
+ });
7168
+ }
7169
+ function ThemeTab({ draft, errors, previewScheme, onChange, onPreviewScheme }) {
6833
7170
  const t = useStrings();
6834
7171
  const { theme } = draft;
6835
7172
  const setColor = (name, value) => onChange({
@@ -6842,38 +7179,129 @@ function ThemeTab({ draft, errors, onChange }) {
6842
7179
  }
6843
7180
  }
6844
7181
  });
7182
+ const setDarkColor = (name, value) => onChange({
7183
+ ...draft,
7184
+ theme: {
7185
+ ...theme,
7186
+ darkColors: {
7187
+ ...theme.darkColors,
7188
+ [name]: value
7189
+ }
7190
+ }
7191
+ });
6845
7192
  return /* @__PURE__ */ jsxs("div", {
6846
7193
  className: "gfa-form",
6847
7194
  children: [
6848
7195
  /* @__PURE__ */ jsx("h2", {
6849
7196
  className: "gfa-section-title",
6850
- children: t("theme.colors")
7197
+ children: t("theme.colors")
7198
+ }),
7199
+ /* @__PURE__ */ jsx(ColorGrid, {
7200
+ colors: theme.colors,
7201
+ defaults: DEFAULT_THEME_COLORS,
7202
+ errors,
7203
+ errorPrefix: "theme.colors",
7204
+ onChange: setColor
7205
+ }),
7206
+ /* @__PURE__ */ jsx("h2", {
7207
+ className: "gfa-section-title",
7208
+ children: t("theme.darkMode")
7209
+ }),
7210
+ /* @__PURE__ */ jsxs("label", {
7211
+ className: "gfa-checkbox",
7212
+ children: [/* @__PURE__ */ jsx("input", {
7213
+ type: "checkbox",
7214
+ checked: theme.darkMode === true,
7215
+ onChange: (event) => onChange({
7216
+ ...draft,
7217
+ theme: {
7218
+ ...theme,
7219
+ darkMode: event.target.checked
7220
+ }
7221
+ })
7222
+ }), t("theme.darkModeOn")]
7223
+ }),
7224
+ /* @__PURE__ */ jsx("p", {
7225
+ className: "gfa-hint",
7226
+ children: t("theme.darkModeHint")
7227
+ }),
7228
+ theme.darkMode && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("fieldset", {
7229
+ className: "gfa-choice-group",
7230
+ children: [/* @__PURE__ */ jsx("legend", {
7231
+ className: "gfa-label",
7232
+ children: t("theme.preview")
7233
+ }), ["light", "dark"].map((scheme) => /* @__PURE__ */ jsxs("label", {
7234
+ className: "gfa-checkbox",
7235
+ children: [/* @__PURE__ */ jsx("input", {
7236
+ type: "radio",
7237
+ name: "preview-scheme",
7238
+ checked: previewScheme === scheme,
7239
+ onChange: () => onPreviewScheme(scheme)
7240
+ }), t(scheme === "light" ? "theme.previewLight" : "theme.previewDark")]
7241
+ }, scheme))]
7242
+ }), /* @__PURE__ */ jsxs("fieldset", {
7243
+ className: "gfa-choice-group",
7244
+ children: [/* @__PURE__ */ jsx("legend", {
7245
+ className: "gfa-label",
7246
+ children: t("theme.darkColors")
7247
+ }), /* @__PURE__ */ jsx(ColorGrid, {
7248
+ colors: theme.darkColors ?? {},
7249
+ defaults: DEFAULT_DARK_THEME_COLORS,
7250
+ errors,
7251
+ errorPrefix: "theme.darkColors",
7252
+ onChange: setDarkColor
7253
+ })]
7254
+ })] }),
7255
+ /* @__PURE__ */ jsx("h2", {
7256
+ className: "gfa-section-title",
7257
+ children: t("theme.code")
6851
7258
  }),
6852
- /* @__PURE__ */ jsx("div", {
6853
- className: "gfa-color-grid",
6854
- children: THEME_COLORS.map((name) => {
6855
- const value = theme.colors[name] ?? "";
6856
- const shown = value || DEFAULT_THEME_COLORS[name];
6857
- return /* @__PURE__ */ jsx(Field, {
6858
- label: t(`theme.color.${name}`),
6859
- error: errors[`theme.colors.${name}`],
6860
- children: (props) => /* @__PURE__ */ jsxs("div", {
6861
- className: "gfa-color-input",
6862
- children: [/* @__PURE__ */ jsx("input", {
6863
- type: "color",
6864
- "aria-label": t("theme.pickColor", { name: t(`theme.color.${name}`) }),
6865
- value: HEX.test(shown) ? shown : "#000000",
6866
- onChange: (event) => setColor(name, event.target.value)
6867
- }), /* @__PURE__ */ jsx("input", {
6868
- ...props,
6869
- className: "gfa-input",
6870
- value,
6871
- placeholder: `${t("theme.default")}: ${DEFAULT_THEME_COLORS[name]}`,
6872
- onChange: (event) => setColor(name, event.target.value)
6873
- })]
6874
- })
6875
- }, name);
7259
+ /* @__PURE__ */ jsx("p", {
7260
+ className: "gfa-hint",
7261
+ children: t("theme.codeHint")
7262
+ }),
7263
+ ["light", "dark"].map((side) => /* @__PURE__ */ jsx(Field, {
7264
+ label: t(side === "light" ? "theme.codeLight" : "theme.codeDark"),
7265
+ children: (props) => /* @__PURE__ */ jsx("select", {
7266
+ ...props,
7267
+ className: "gfa-input",
7268
+ value: theme.code?.[side] ?? DEFAULT_CODE_THEMES[side],
7269
+ onChange: (event) => onChange({
7270
+ ...draft,
7271
+ theme: {
7272
+ ...theme,
7273
+ code: {
7274
+ ...theme.code,
7275
+ [side]: event.target.value
7276
+ }
7277
+ }
7278
+ }),
7279
+ children: Object.entries(CODE_THEMES[side]).map(([name, label]) => /* @__PURE__ */ jsx("option", {
7280
+ value: name,
7281
+ children: label
7282
+ }, name))
6876
7283
  })
7284
+ }, side)),
7285
+ /* @__PURE__ */ jsxs("label", {
7286
+ className: "gfa-checkbox",
7287
+ children: [/* @__PURE__ */ jsx("input", {
7288
+ type: "checkbox",
7289
+ checked: theme.code?.background === "site",
7290
+ onChange: (event) => onChange({
7291
+ ...draft,
7292
+ theme: {
7293
+ ...theme,
7294
+ code: {
7295
+ ...theme.code,
7296
+ background: event.target.checked ? "site" : void 0
7297
+ }
7298
+ }
7299
+ })
7300
+ }), t("theme.codeBackground")]
7301
+ }),
7302
+ /* @__PURE__ */ jsx("p", {
7303
+ className: "gfa-hint",
7304
+ children: t("theme.codeBackgroundHint")
6877
7305
  }),
6878
7306
  /* @__PURE__ */ jsx("h2", {
6879
7307
  className: "gfa-section-title",
@@ -6887,13 +7315,14 @@ function ThemeTab({ draft, errors, onChange }) {
6887
7315
  label: t("theme.headingFont"),
6888
7316
  value: theme.fonts.heading ?? "",
6889
7317
  error: errors["theme.fonts.heading"],
6890
- onChange: (heading) => onChange({
7318
+ onChange: (heading, headingCategory) => onChange({
6891
7319
  ...draft,
6892
7320
  theme: {
6893
7321
  ...theme,
6894
7322
  fonts: {
6895
7323
  ...theme.fonts,
6896
- heading
7324
+ heading,
7325
+ headingCategory
6897
7326
  }
6898
7327
  }
6899
7328
  })
@@ -6902,13 +7331,14 @@ function ThemeTab({ draft, errors, onChange }) {
6902
7331
  label: t("theme.bodyFont"),
6903
7332
  value: theme.fonts.body ?? "",
6904
7333
  error: errors["theme.fonts.body"],
6905
- onChange: (body) => onChange({
7334
+ onChange: (body, bodyCategory) => onChange({
6906
7335
  ...draft,
6907
7336
  theme: {
6908
7337
  ...theme,
6909
7338
  fonts: {
6910
7339
  ...theme.fonts,
6911
- body
7340
+ body,
7341
+ bodyCategory
6912
7342
  }
6913
7343
  }
6914
7344
  })
@@ -7154,10 +7584,34 @@ function SettingsScreen({ tab, section }) {
7154
7584
  content.collections,
7155
7585
  homeView
7156
7586
  ]);
7587
+ const [resolvedHome, setResolvedHome] = useState();
7588
+ useEffect(() => {
7589
+ if (!home) return;
7590
+ let current = true;
7591
+ const timer = setTimeout(() => {
7592
+ resolveAllData(home.content.data, pageConfig, siteMetadata(previewSite)).then((data) => current && setResolvedHome(data), () => {});
7593
+ }, 300);
7594
+ return () => {
7595
+ current = false;
7596
+ clearTimeout(timer);
7597
+ };
7598
+ }, [
7599
+ home,
7600
+ pageConfig,
7601
+ previewSite
7602
+ ]);
7603
+ const adminDark = useDarkMode();
7604
+ const [chosenScheme, setChosenScheme] = useState();
7605
+ const previewScheme = chosenScheme ?? (adminDark ? "dark" : "light");
7157
7606
  const previewStyles = useMemo(() => ({
7158
7607
  theme: previewSettings.theme,
7159
- customCss: css
7160
- }), [previewSettings, css]);
7608
+ customCss: css,
7609
+ scheme: previewScheme
7610
+ }), [
7611
+ previewSettings,
7612
+ css,
7613
+ previewScheme
7614
+ ]);
7161
7615
  const errors = showErrors ? validation.errors : {};
7162
7616
  return /* @__PURE__ */ jsxs("div", {
7163
7617
  className: "gfa-split",
@@ -7212,7 +7666,9 @@ function SettingsScreen({ tab, section }) {
7212
7666
  tab === "theme" && /* @__PURE__ */ jsx(ThemeTab, {
7213
7667
  draft: settings,
7214
7668
  errors,
7215
- onChange: setSettings
7669
+ previewScheme,
7670
+ onChange: setSettings,
7671
+ onPreviewScheme: setChosenScheme
7216
7672
  }),
7217
7673
  tab === "menus" && /* @__PURE__ */ jsx(MenusEditor, {
7218
7674
  menus,
@@ -7249,7 +7705,7 @@ function SettingsScreen({ tab, section }) {
7249
7705
  site: previewSite,
7250
7706
  pageConfig,
7251
7707
  layoutConfig,
7252
- page: home.content.data,
7708
+ page: resolvedHome ?? home.content.data,
7253
7709
  header: content.header.data,
7254
7710
  footer: content.footer.data
7255
7711
  })
@@ -7534,7 +7990,7 @@ function ConnectedDomain({ domain, onRemoved }) {
7534
7990
  className: "gfa-form",
7535
7991
  children: [
7536
7992
  /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx("strong", { children: t("domain.connected", { domain }) }) }),
7537
- account?.canRedirect && /* @__PURE__ */ jsx("p", {
7993
+ account?.redirectAddressesListed && /* @__PURE__ */ jsx("p", {
7538
7994
  className: "gfa-notice",
7539
7995
  children: t("domain.signIn", {
7540
7996
  host,
@@ -8636,7 +9092,7 @@ function EntryEditor({ collection, entry }) {
8636
9092
  const values = Object.fromEntries(Object.entries(next.root.props ?? {}).filter(([name]) => name !== "id"));
8637
9093
  const title = typeof values.title === "string" && values.title.trim() ? values.title.trim() : entry.slug;
8638
9094
  return {
8639
- changes: [entryFileChange(collection, entry.slug, values)],
9095
+ changes: [entryFileChange$1(collection, entry.slug, values)],
8640
9096
  message: t("editEntry.message", {
8641
9097
  entry: entryName,
8642
9098
  title
@@ -8747,9 +9203,10 @@ function MovePageDialog({ page, onClose }) {
8747
9203
  const t = useStrings();
8748
9204
  const { publish } = useAdmin();
8749
9205
  const { content } = useSiteContent();
9206
+ const owner = useCanChangeSettings();
8750
9207
  const [address, setAddress] = useState(page.path);
8751
9208
  const [updateLinks, setUpdateLinks] = useState(true);
8752
- const canRedirect = page.path !== "/";
9209
+ const canRedirect = page.path !== "/" && owner;
8753
9210
  const [redirect, setRedirect] = useState(canRedirect);
8754
9211
  const [submitted, setSubmitted] = useState(false);
8755
9212
  const [busy, setBusy] = useState(false);
@@ -8948,147 +9405,108 @@ function PagesScreen() {
8948
9405
  });
8949
9406
  }
8950
9407
  //#endregion
8951
- //#region src/sign-in.tsx
8952
- function errorMessage(error) {
8953
- if (error instanceof SignInError) return `signIn.error.${error.problem}`;
8954
- return "signIn.error.other";
9408
+ //#region src/site-preview.tsx
9409
+ /**
9410
+ * The page tools such as the Claude connector take screenshots of drafts with.
9411
+ * At `/admin/#/preview`, the admin panel shows nothing and signs nobody in
9412
+ * until a script in the page calls `window.goodfellowPreview()` with a draft's
9413
+ * content files and an address; it then shows that page as the live site
9414
+ * would. Only code already running in the page can call it, so no link and no
9415
+ * other site can put content here.
9416
+ */
9417
+ /** The admin panel's address for the preview page. */
9418
+ const PREVIEW_ROUTE = "#/preview";
9419
+ /** Whether the admin panel was opened at the preview page. */
9420
+ function isPreviewAddress(hash) {
9421
+ return hash === PREVIEW_ROUTE;
8955
9422
  }
8956
- function SignInScreen({ host, error, onSignedIn }) {
8957
- const t = useStrings();
8958
- const [token, setToken] = useState("");
8959
- const [remember, setRemember] = useState(false);
8960
- const [busy, setBusy] = useState(false);
8961
- const [failure, setFailure] = useState(error);
8962
- const values = { host: host.name };
8963
- const onSubmit = async (event) => {
8964
- event.preventDefault();
8965
- setBusy(true);
8966
- setFailure(void 0);
8967
- try {
8968
- onSignedIn(await host.signInWithToken(token, remember));
8969
- } catch (caught) {
8970
- setFailure(caught);
8971
- setBusy(false);
8972
- }
9423
+ function filesSource(files) {
9424
+ return {
9425
+ read: async (path) => Object.hasOwn(files, path) ? files[path] : void 0,
9426
+ list: async (dir) => Object.keys(files).filter((path) => path.startsWith(`${dir}/`)).sort()
8973
9427
  };
8974
- const remembered = /* @__PURE__ */ jsxs("label", {
8975
- className: "gfa-checkbox",
8976
- children: [/* @__PURE__ */ jsx("input", {
8977
- type: "checkbox",
8978
- checked: remember,
8979
- onChange: (event) => setRemember(event.target.checked)
8980
- }), /* @__PURE__ */ jsxs("span", { children: [t("signIn.remember"), /* @__PURE__ */ jsx("span", {
8981
- className: "gfa-hint gfa-block",
8982
- children: t("signIn.rememberHint")
8983
- })] })]
8984
- });
8985
- return /* @__PURE__ */ jsxs("div", {
8986
- className: "gfa-sign-in",
8987
- children: [
8988
- /* @__PURE__ */ jsx("h1", { children: t("signIn.title") }),
8989
- /* @__PURE__ */ jsx("p", { children: t("signIn.intro", values) }),
8990
- failure !== void 0 && /* @__PURE__ */ jsx(ErrorMessage, {
8991
- message: failure instanceof SignInError && failure.problem === "invalid" && error === failure ? t("signIn.error.expired") : t(errorMessage(failure), values),
8992
- error: failure
8993
- }),
8994
- host.canRedirect && /* @__PURE__ */ jsxs("div", {
8995
- className: "gfa-form",
8996
- children: [remembered, /* @__PURE__ */ jsx(Button, {
8997
- variant: "primary",
8998
- disabled: busy,
8999
- onClick: () => void host.startRedirect(remember),
9000
- children: t("signIn.redirect", values)
9001
- })]
9002
- }),
9003
- host.tokenLinks && host.tokenLinks.length > 0 && /* @__PURE__ */ jsxs("details", {
9004
- className: "gfa-sign-in-token",
9005
- open: !host.canRedirect,
9006
- children: [/* @__PURE__ */ jsx("summary", { children: t(host.canRedirect ? "signIn.orToken" : "signIn.token.title") }), /* @__PURE__ */ jsxs("form", {
9007
- className: "gfa-form",
9008
- onSubmit,
9009
- children: [
9010
- /* @__PURE__ */ jsxs("ol", {
9011
- className: "gfa-steps",
9012
- children: [/* @__PURE__ */ jsxs("li", { children: [
9013
- /* @__PURE__ */ jsx("p", { children: t("signIn.token.step1", values) }),
9014
- /* @__PURE__ */ jsx("ul", {
9015
- className: "gfa-token-links",
9016
- children: host.tokenLinks.map((link) => /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx("a", {
9017
- href: link.url,
9018
- target: "_blank",
9019
- rel: "noreferrer",
9020
- children: t(link.label, values)
9021
- }), link.hint && /* @__PURE__ */ jsx("p", {
9022
- className: "gfa-hint",
9023
- children: t(link.hint, values)
9024
- })] }, link.url))
9025
- }),
9026
- /* @__PURE__ */ jsx("p", {
9027
- className: "gfa-hint",
9028
- children: t("signIn.token.chooseSite", values)
9029
- })
9030
- ] }), /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx("p", { children: t("signIn.token.step2") }), /* @__PURE__ */ jsx(TextField, {
9031
- label: t("signIn.token.label"),
9032
- type: "password",
9033
- autoComplete: "off",
9034
- spellCheck: false,
9035
- value: token,
9036
- onChange: setToken
9037
- })] })]
9038
- }),
9039
- !host.canRedirect && remembered,
9040
- /* @__PURE__ */ jsx(Button, {
9041
- type: "submit",
9042
- variant: "primary",
9043
- disabled: busy || !token.trim(),
9044
- children: busy ? t("signIn.checking") : t("signIn.submit")
9045
- })
9046
- ]
9047
- })]
9048
- })
9049
- ]
9050
- });
9051
9428
  }
9429
+ const EMPTY_DOCUMENT = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"></head><body></body></html>";
9052
9430
  /**
9053
- * Shows the sign-in screen until someone is signed in to the git host, then
9054
- * renders its children with the connected backend.
9431
+ * Shows a page from content handed to `window.goodfellowPreview()`, full width,
9432
+ * in an iframe as tall as the page, so the site's styles and the admin panel's
9433
+ * don't mix and a full-page screenshot shows all of it.
9055
9434
  */
9056
- function SignInGate({ host, children }) {
9057
- const t = useStrings();
9058
- const [state, setState] = useState({ status: "checking" });
9435
+ function SitePreview({ config, preview }) {
9436
+ const configs = useMemo(() => createPuckConfigs(config), [config]);
9437
+ const [doc, setDoc] = useState(null);
9438
+ const [shown, setShown] = useState();
9439
+ const [height, setHeight] = useState(800);
9440
+ const rendered = useRef(void 0);
9059
9441
  useEffect(() => {
9060
- let cancelled = false;
9061
- host.restore().then((backend) => !cancelled && setState(backend ? {
9062
- status: "signed-in",
9063
- backend
9064
- } : { status: "signed-out" }), (error) => !cancelled && setState({
9065
- status: "signed-out",
9066
- error
9067
- }));
9442
+ window.goodfellowPreview = async ({ files, path, dark }) => {
9443
+ let content;
9444
+ try {
9445
+ content = await loadSiteContent(filesSource(files));
9446
+ } catch (error) {
9447
+ return {
9448
+ ok: false,
9449
+ problem: error.message
9450
+ };
9451
+ }
9452
+ const match = findSitePage(content, todayIn(content.settings.timeZone), config.blocks, path);
9453
+ if (!match) return {
9454
+ ok: false,
9455
+ problem: `The site has no page at ${path}.`
9456
+ };
9457
+ const prepared = await preparePage(configs, content, match.page);
9458
+ await new Promise((done) => {
9459
+ rendered.current = done;
9460
+ setShown({
9461
+ content,
9462
+ prepared,
9463
+ dark: dark === true
9464
+ });
9465
+ });
9466
+ return {
9467
+ ok: true,
9468
+ status: match.status
9469
+ };
9470
+ };
9068
9471
  return () => {
9069
- cancelled = true;
9472
+ delete window.goodfellowPreview;
9070
9473
  };
9071
- }, [host]);
9072
- if (state.status === "checking") return /* @__PURE__ */ jsx("p", {
9073
- className: "gfa-screen",
9074
- children: t("signIn.checking")
9075
- });
9076
- if (state.status === "signed-out") return /* @__PURE__ */ jsx(SignInScreen, {
9077
- host,
9078
- error: state.error,
9079
- onSignedIn: (backend) => setState({
9080
- status: "signed-in",
9081
- backend
9082
- })
9083
- });
9084
- const { backend } = state;
9085
- return children(backend, (error) => {
9086
- backend.signOut();
9087
- setState({
9088
- status: "signed-out",
9089
- error
9474
+ }, [config.blocks, configs]);
9475
+ useEffect(() => {
9476
+ if (!doc || !shown) return;
9477
+ installPreviewStyles(doc, preview, {
9478
+ theme: shown.content.settings.theme,
9479
+ customCss: shown.content.customCss,
9480
+ scheme: shown.dark ? "dark" : "light"
9090
9481
  });
9091
- });
9482
+ requestAnimationFrame(() => requestAnimationFrame(() => {
9483
+ setHeight(Math.max(doc.documentElement.scrollHeight, 200));
9484
+ rendered.current?.();
9485
+ rendered.current = void 0;
9486
+ }));
9487
+ }, [
9488
+ doc,
9489
+ shown,
9490
+ preview
9491
+ ]);
9492
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("iframe", {
9493
+ title: "Preview",
9494
+ srcDoc: EMPTY_DOCUMENT,
9495
+ onLoad: (event) => setDoc(event.currentTarget.contentDocument),
9496
+ style: {
9497
+ display: "block",
9498
+ width: "100%",
9499
+ height,
9500
+ border: 0
9501
+ }
9502
+ }), doc && shown && createPortal(/* @__PURE__ */ jsx(PageBody, {
9503
+ site: shown.prepared.site,
9504
+ pageConfig: shown.prepared.pageConfig,
9505
+ layoutConfig: shown.prepared.layoutConfig,
9506
+ page: shown.prepared.data,
9507
+ header: shown.prepared.header,
9508
+ footer: shown.prepared.footer
9509
+ }), doc.body)] });
9092
9510
  }
9093
9511
  //#endregion
9094
9512
  //#region src/updates-screen.tsx
@@ -9131,7 +9549,7 @@ async function previousRelease(versions, current) {
9131
9549
  /** The site's Goodfellow release, the fixes and releases it can update to, and its nightly update job. */
9132
9550
  function UpdatesScreen() {
9133
9551
  const t = useStrings();
9134
- const { updates, demo } = useAdmin();
9552
+ const { updates, demo, contentOnly } = useAdmin();
9135
9553
  return /* @__PURE__ */ jsxs("div", {
9136
9554
  className: "gfa-screen",
9137
9555
  children: [
@@ -9142,7 +9560,7 @@ function UpdatesScreen() {
9142
9560
  /* @__PURE__ */ jsx(SettingsTabs, { tab: "updates" }),
9143
9561
  /* @__PURE__ */ jsxs("div", {
9144
9562
  className: "gfa-form",
9145
- 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") })]
9146
9564
  })
9147
9565
  ]
9148
9566
  });
@@ -9160,8 +9578,9 @@ function UpdatesPanel() {
9160
9578
  const load = useCallback(async () => {
9161
9579
  if (!updates) return;
9162
9580
  try {
9581
+ const code = updates.code;
9163
9582
  const [packageJson, settingsText, metadata, setup, last, people] = await Promise.all([
9164
- versions?.readAt("package.json", revision),
9583
+ code ? code.read("package.json").catch(() => void 0) : versions?.readAt("package.json", revision),
9165
9584
  readFile(UPDATES_FILE),
9166
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),
9167
9586
  updates.setup(),
@@ -9170,7 +9589,8 @@ function UpdatesPanel() {
9170
9589
  ]);
9171
9590
  const current = packageJson ? siteVersion(packageJson) : void 0;
9172
9591
  const settings = parseUpdateSettings(settingsText);
9173
- 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;
9174
9594
  setLoaded({
9175
9595
  current,
9176
9596
  previous,
@@ -9216,6 +9636,20 @@ function UpdatesPanel() {
9216
9636
  setBusy(false);
9217
9637
  };
9218
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));
9219
9653
  const saveSettings = (settings, message) => act(async () => {
9220
9654
  const result = await publish([updateSettingsFileChange(settings)], message);
9221
9655
  if (!result.ok) throw new SaveError(result.error);
@@ -9232,7 +9666,20 @@ function UpdatesPanel() {
9232
9666
  const { current, previous, settings, available, setup, last, owner } = loaded;
9233
9667
  const needsToken = problem instanceof UpdatesError && problem.problem === "not-allowed" && ownerAccess;
9234
9668
  return /* @__PURE__ */ jsxs(Fragment, { children: [
9235
- /* @__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") }),
9236
9683
  /* @__PURE__ */ jsx(OwnerTokenActive, { onForget: () => void load() }),
9237
9684
  setup === "missing" && /* @__PURE__ */ jsx("p", {
9238
9685
  className: "gfa-notice",
@@ -9246,7 +9693,7 @@ function UpdatesPanel() {
9246
9693
  children: t("updates.enable")
9247
9694
  })]
9248
9695
  }),
9249
- current && setup !== "missing" && /* @__PURE__ */ jsxs(Fragment, { children: [
9696
+ current && setup !== "missing" && setup !== "no-access" && /* @__PURE__ */ jsxs(Fragment, { children: [
9250
9697
  /* @__PURE__ */ jsx("h2", {
9251
9698
  className: "gfa-section-title",
9252
9699
  children: t("updates.fixes.title")
@@ -9309,7 +9756,7 @@ function UpdatesPanel() {
9309
9756
  /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
9310
9757
  disabled: busy || running,
9311
9758
  onClick: () => {
9312
- 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 ?? "");
9313
9760
  },
9314
9761
  children: t("updates.back.button", { version: previous })
9315
9762
  }) })
@@ -9415,9 +9862,32 @@ function LoadError({ error }) {
9415
9862
  })]
9416
9863
  });
9417
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
+ }
9418
9887
  function CollectionRoute({ id, sub, slug }) {
9419
9888
  const t = useStrings();
9420
9889
  const { content } = useSiteContent();
9890
+ const owner = useCanChangeSettings();
9421
9891
  if (sub === "edit") return /* @__PURE__ */ jsx(EntryEditorScreen, {
9422
9892
  collectionId: id,
9423
9893
  slug
@@ -9430,6 +9900,7 @@ function CollectionRoute({ id, sub, slug }) {
9430
9900
  children: t("collection.back")
9431
9901
  })]
9432
9902
  });
9903
+ if ((sub === "template" || sub === "settings") && !owner) return /* @__PURE__ */ jsx(OwnersOnly, {});
9433
9904
  if (sub === "template") return /* @__PURE__ */ jsx(TemplateScreen, { collection });
9434
9905
  if (sub === "settings") return /* @__PURE__ */ jsx(CollectionSettingsScreen, { collection }, collection.id);
9435
9906
  return /* @__PURE__ */ jsx(EntriesScreen, { collection });
@@ -9437,8 +9908,11 @@ function CollectionRoute({ id, sub, slug }) {
9437
9908
  function Screen() {
9438
9909
  const { segments, params } = useRoute();
9439
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, {});
9440
9914
  if (section === "media") return /* @__PURE__ */ jsx(MediaScreen, {});
9441
- if (section === "blocks") return /* @__PURE__ */ jsx(BlocksScreen, {});
9915
+ if (section === "blocks") return contentOnly ? /* @__PURE__ */ jsx(BlocksInCode, {}) : /* @__PURE__ */ jsx(BlocksScreen, {});
9442
9916
  if (section === "collections") return sub ? /* @__PURE__ */ jsx(CollectionRoute, {
9443
9917
  id: sub,
9444
9918
  sub: tab,
@@ -9529,7 +10003,7 @@ function AccountMenu() {
9529
10003
  title: t("account.signedInAs", { name: user.login }),
9530
10004
  children: user.name || user.login
9531
10005
  }),
9532
- /* @__PURE__ */ jsx(Button, {
10006
+ account.signOut && /* @__PURE__ */ jsx(Button, {
9533
10007
  variant: "ghost",
9534
10008
  onClick: account.signOut,
9535
10009
  children: t("account.signOut")
@@ -9569,7 +10043,8 @@ function versionsSection(file) {
9569
10043
  }
9570
10044
  function Shell() {
9571
10045
  const t = useStrings();
9572
- const { state, siteUrl } = useAdmin();
10046
+ const { state, siteUrl, contentOnly } = useAdmin();
10047
+ const owner = useCanChangeSettings();
9573
10048
  const { segments, params } = useRoute();
9574
10049
  const [section] = segments;
9575
10050
  const current = section === "versions" ? versionsSection(params.get("file") ?? "") : section === "layout" || section === "settings" || section === "collections" || section === "media" || section === "blocks" ? section : "pages";
@@ -9607,12 +10082,12 @@ function Shell() {
9607
10082
  "aria-current": current === "layout" ? "page" : void 0,
9608
10083
  children: t("nav.layout")
9609
10084
  }),
9610
- /* @__PURE__ */ jsx(AppLink, {
10085
+ owner && !contentOnly && /* @__PURE__ */ jsx(AppLink, {
9611
10086
  href: "#/blocks",
9612
10087
  "aria-current": current === "blocks" ? "page" : void 0,
9613
10088
  children: t("nav.blocks")
9614
10089
  }),
9615
- /* @__PURE__ */ jsx(AppLink, {
10090
+ owner && /* @__PURE__ */ jsx(AppLink, {
9616
10091
  href: "#/settings/general",
9617
10092
  "aria-current": current === "settings" ? "page" : void 0,
9618
10093
  children: t("nav.settings")
@@ -9654,7 +10129,7 @@ function MissingBackend() {
9654
10129
  });
9655
10130
  }
9656
10131
  /** The admin panel. Render it on its own page, such as `/admin`. */
9657
- function Admin({ config, store, host = config.backend, preview, siteUrl = "/", strings, registry }) {
10132
+ function Admin({ config, store, host = config.backend, server, preview, siteUrl = "/", strings, registry }) {
9658
10133
  useApplyThemeChoice();
9659
10134
  const demo = useMemo(() => config.demo ? createDemoStore(siteUrl, store) : void 0, [
9660
10135
  config.demo,
@@ -9665,6 +10140,10 @@ function Admin({ config, store, host = config.backend, preview, siteUrl = "/", s
9665
10140
  ...strings,
9666
10141
  "publish.done": strings?.["demo.published"] ?? defaultStrings["demo.published"]
9667
10142
  } : strings, [demo, strings]);
10143
+ if (typeof location !== "undefined" && isPreviewAddress(location.hash)) return /* @__PURE__ */ jsx(SitePreview, {
10144
+ config,
10145
+ preview
10146
+ });
9668
10147
  return /* @__PURE__ */ jsx(StringsProvider, {
9669
10148
  strings: text,
9670
10149
  children: demo ? /* @__PURE__ */ jsx(AdminProvider, {
@@ -9681,6 +10160,31 @@ function Admin({ config, store, host = config.backend, preview, siteUrl = "/", s
9681
10160
  siteUrl,
9682
10161
  registry,
9683
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
+ })
9684
10188
  }) : host ? /* @__PURE__ */ jsx(SignInGate, {
9685
10189
  host,
9686
10190
  children: (backend, signOut) => /* @__PURE__ */ jsx(AdminProvider, {
@@ -9689,7 +10193,7 @@ function Admin({ config, store, host = config.backend, preview, siteUrl = "/", s
9689
10193
  account: {
9690
10194
  user: backend.user,
9691
10195
  hostName: host.name,
9692
- canRedirect: host.canRedirect,
10196
+ redirectAddressesListed: host.canRedirect && host.redirectAddressesListed === true,
9693
10197
  signOut: () => {
9694
10198
  forgetAiKeys();
9695
10199
  signOut();