@goodfellow-cms/admin 0.2.0 → 0.3.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 { CODE_THEMES, COLLECTIONS_DIR, COLOR_SCHEME_ATTRIBUTE, ConflictError, ContentError, DEFAULT_CODE_THEMES, DEFAULT_DARK_THEME_COLORS, DEFAULT_THEME_COLORS, DEMO_CONTENT_PATH, DomainError, EditorsError, FIELD_TYPES, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, HeadCodeError, INSTALLED_RECORD_FILE, MEDIA_DIR, NPM_REGISTRY, REDIRECT_LINK_PLACEHOLDER, REDIRECT_MESSAGE, RegistryError, SignInError, THEME_COLORS, TITLE_FIELD, UPDATES_FILE, UpdatesError, VERSION_PACKAGE, WEEKDAYS, addressPatternFor, addressPatternProblem, allPages, availableBlocks, availableUpdates, blockUses, canFormatMarkdown, checkEntrySlug, checkPageAddress, checkRecords, cleanEventValue, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, collectionFileSchema, compareReleases, credentialStorage, customCssFileChange, datePart, deleteCollectionChanges, demoContentStore, demoStore, domainZone, entryAddress, entryFileChange, entryFileChange as entryFileChange$1, entryTitle, escapeStyleText, eventValueProblem, findEntry, findSitePage, formTarget, formatDate, formatOccurrence, goodfellowRegistryUrl, googleFontsUrl, isAddressSegment, isAllDay, isDemoStore, isEmptyValue, isEventLike, isReservedPagePath, layoutFileChange, loadSiteContent, markdownToHtml, mediaFileChange, menusFileChange, menusFileSchema, moveEntryChanges, movePageChanges, newCollectionSettings, newPageData, normalizeDomain, pageFileChange, pageTitle, pageView, parseDemoContent, parseHeadCode, parseInstalledRecord, parseUpdateSettings, planBlockChanges, recordsInPlace, redirectFrom, redirectProblems, redirectsFileChange, registrarName, releaseVersions, serializeContent, shownOccurrence, sitePages, siteSettingsFileChange, siteSettingsSchema, siteVersion, slugify, storedCss, storedData, storedEntryFields, storedRedirects, themeToCss, todayIn, trimChars, uniqueName, updateSettingsFileChange, wallClock, weekdayName, weekdayOf, withoutCredits, writeChanges } from "@goodfellow-cms/core";
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.3.0"),
823
389
  ...config.registries
824
390
  }), [registry, config.registries]);
825
391
  const favicon = state.status === "ready" ? state.content.settings.favicon : void 0;
@@ -837,6 +403,8 @@ function AdminProvider({ config, store, account, preview, siteUrl, registry, onS
837
403
  const ownerAccess = useMemo(() => backendOwner && {
838
404
  tokenLink: backendOwner.tokenLink,
839
405
  active: ownerActive,
406
+ problem: backendOwner.problem,
407
+ ...backendOwner.startRedirect && { startRedirect: (options) => backendOwner.startRedirect?.(options) ?? Promise.resolve() },
840
408
  async applyToken(token) {
841
409
  await backendOwner.applyToken(token);
842
410
  setOwnerActive(true);
@@ -1050,6 +618,7 @@ function useAiSettings(providers) {
1050
618
  else storage.clear();
1051
619
  }
1052
620
  savePreferences({
621
+ ...preferences,
1053
622
  provider,
1054
623
  models: {
1055
624
  ...preferences.models,
@@ -1061,6 +630,17 @@ function useAiSettings(providers) {
1061
630
  });
1062
631
  }, [])];
1063
632
  }
633
+ /** Whether the editor lets the AI find openly licensed photos, remembered in this browser. On at first. */
634
+ function useStockPhotoChoice() {
635
+ const [on, setOn] = useState(() => loadPreferences().stockPhotos !== false);
636
+ return [on, useCallback((next) => {
637
+ setOn(next);
638
+ savePreferences({
639
+ ...loadPreferences(),
640
+ stockPhotos: next
641
+ });
642
+ }, [])];
643
+ }
1064
644
  //#endregion
1065
645
  //#region src/router.tsx
1066
646
  function parseHash(hash) {
@@ -1170,6 +750,13 @@ const defaultStrings = {
1170
750
  "signIn.error.no-access": "That account can't see this site. Make sure the token was given access to it.",
1171
751
  "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
752
  "signIn.error.redirect-failed": "Signing in didn't finish. Try again.",
753
+ "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.",
754
+ "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.",
755
+ "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.",
756
+ "signIn.error.local-address": "Signing in with {host} works only on the published site. Sign in with an access token here.",
757
+ "signIn.error.not-owner": "That account isn't an owner of this site on {host}.",
758
+ "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.",
759
+ "signIn.install": "Install Goodfellow on {host}",
1173
760
  "signIn.error.expired": "You were signed out because your sign-in expired. Sign in again to keep editing.",
1174
761
  "signIn.error.other": "Couldn't reach {host}. Check your connection and try again.",
1175
762
  "account.signedInAs": "Signed in as {name}",
@@ -1496,6 +1083,7 @@ const defaultStrings = {
1496
1083
  "media.pickerTitleVideo": "Choose a video",
1497
1084
  "media.pickerEmpty": "Nothing has been uploaded yet. Upload a file to use it here.",
1498
1085
  "media.clear": "Remove",
1086
+ "media.credit": "Credit: {credit}",
1499
1087
  "ai.label": "AI",
1500
1088
  "ai.title": "Write with AI",
1501
1089
  "ai.mode.add": "Describe what to add to this page, and AI will write it at the end of the page.",
@@ -1505,6 +1093,15 @@ const defaultStrings = {
1505
1093
  "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
1094
  "ai.placeholder.edit": "For example: make it shorter and friendlier, or translate it into Spanish.",
1507
1095
  "ai.placeholder.fill": "For example: Fr. Smith's homily from Sunday, about hope. Paste any notes here.",
1096
+ "ai.photos": "Find openly licensed photos where the media library has none that fit",
1097
+ "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.",
1098
+ "ai.photos.finding": "Finding photos…",
1099
+ "ai.photos.message": "Add photos from Openverse: {names}",
1100
+ "ai.photos.added": "Photos from Openverse added to the media library: {names}.",
1101
+ "ai.photos.missing": "No photo was found for {count} of the images, so they're left empty.",
1102
+ "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.",
1103
+ "ai.photos.error.unreachable": "Couldn't reach Openverse, so some images are left empty. Check your connection, or choose them from the media library.",
1104
+ "ai.photos.error.publish": "The photos found couldn't be added to the media library, so their images are left empty.",
1508
1105
  "ai.write": "Write it",
1509
1106
  "ai.change": "Change it",
1510
1107
  "ai.fill": "Fill it in",
@@ -1612,6 +1209,10 @@ const defaultStrings = {
1612
1209
  "domain.error.invalid": "{host} doesn't accept this domain.",
1613
1210
  "domain.error.not-ready": "The HTTPS certificate isn't ready yet. Try again in a while.",
1614
1211
  "domain.error.other": "Something went wrong.",
1212
+ "owner.signIn.title": "Sign in as an owner",
1213
+ "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.",
1214
+ "owner.signIn.button": "Sign in as an owner with {host}",
1215
+ "owner.signIn.orToken": "Or use an owner token",
1615
1216
  "owner.token.title": "Use an owner token",
1616
1217
  "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
1218
  "owner.token.create": "Create an owner token on {host}",
@@ -1733,6 +1334,8 @@ const defaultStrings = {
1733
1334
  "general.favicon": "Browser tab icon",
1734
1335
  "general.logo": "Logo",
1735
1336
  "general.logoAlt": "Logo description",
1337
+ "general.logoDark": "Logo in dark mode",
1338
+ "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
1339
  "general.socialImage": "Image when shared on social media",
1737
1340
  "general.contact": "Contact details",
1738
1341
  "general.contactHint": "Shown wherever a Contact details block is placed, such as the footer.",
@@ -1787,7 +1390,20 @@ const defaultStrings = {
1787
1390
  "theme.color.accentForeground": "Text on highlight",
1788
1391
  "theme.color.border": "Borders",
1789
1392
  "theme.default": "Default",
1393
+ "theme.darkMode": "Dark mode",
1394
+ "theme.darkModeOn": "Follow each visitor's light or dark setting",
1395
+ "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.",
1396
+ "theme.darkColors": "Colors in dark mode",
1397
+ "theme.preview": "Preview",
1398
+ "theme.previewLight": "Light",
1399
+ "theme.previewDark": "Dark",
1790
1400
  "theme.pickColor": "Choose a color for {name}",
1401
+ "theme.code": "Code colors",
1402
+ "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.",
1403
+ "theme.codeLight": "Light colors",
1404
+ "theme.codeDark": "Dark colors",
1405
+ "theme.codeBackground": "Show code on the subtle background color",
1406
+ "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
1407
  "menus.intro": "Menus are lists of links. Show one anywhere with the Menu block, using its name.",
1792
1408
  "menus.empty": "There are no menus yet.",
1793
1409
  "menus.add": "Add menu",
@@ -2293,6 +1909,68 @@ function AppLink(props) {
2293
1909
  });
2294
1910
  }
2295
1911
  //#endregion
1912
+ //#region src/theme.ts
1913
+ const KEY = "goodfellow-admin-theme";
1914
+ const listeners = /* @__PURE__ */ new Set();
1915
+ function read() {
1916
+ try {
1917
+ const saved = localStorage.getItem(KEY);
1918
+ return saved === "light" || saved === "dark" ? saved : "system";
1919
+ } catch {
1920
+ return "system";
1921
+ }
1922
+ }
1923
+ let current;
1924
+ function choice() {
1925
+ current ??= read();
1926
+ return current;
1927
+ }
1928
+ /** Shows the choice: the stylesheet's colors follow `data-gfa-theme`, or the computer's setting without it. */
1929
+ function apply(next) {
1930
+ const root = document.documentElement;
1931
+ if (next === "system") delete root.dataset.gfaTheme;
1932
+ else root.dataset.gfaTheme = next;
1933
+ }
1934
+ function setThemeChoice(next) {
1935
+ current = next;
1936
+ try {
1937
+ if (next === "system") localStorage.removeItem(KEY);
1938
+ else localStorage.setItem(KEY, next);
1939
+ } catch {}
1940
+ apply(next);
1941
+ for (const listener of listeners) listener();
1942
+ }
1943
+ function subscribe(listener) {
1944
+ listeners.add(listener);
1945
+ const media = matchMedia("(prefers-color-scheme: dark)");
1946
+ media.addEventListener("change", listener);
1947
+ return () => {
1948
+ listeners.delete(listener);
1949
+ media.removeEventListener("change", listener);
1950
+ };
1951
+ }
1952
+ /** The choice, kept in this browser. */
1953
+ function useThemeChoice() {
1954
+ return useSyncExternalStore(subscribe, choice, () => "system");
1955
+ }
1956
+ /** Applies the choice to the page while the admin panel is open. Used once, by the admin panel itself. */
1957
+ function useApplyThemeChoice() {
1958
+ const value = useThemeChoice();
1959
+ useEffect(() => {
1960
+ apply(value);
1961
+ return () => {
1962
+ delete document.documentElement.dataset.gfaTheme;
1963
+ };
1964
+ }, [value]);
1965
+ }
1966
+ /** Whether the admin panel is dark at the moment, by choice or by the computer's setting. */
1967
+ function useDarkMode() {
1968
+ return useSyncExternalStore(subscribe, () => {
1969
+ const value = choice();
1970
+ return value === "dark" || value === "system" && matchMedia("(prefers-color-scheme: dark)").matches;
1971
+ }, () => false);
1972
+ }
1973
+ //#endregion
2296
1974
  //#region src/preview.tsx
2297
1975
  function upsert(doc, key, tag, setup) {
2298
1976
  let element = doc.head.querySelector(`[data-gf="${key}"]`);
@@ -2310,6 +1988,7 @@ function upsert(doc, key, tag, setup) {
2310
1988
  * browser too, so classes typed in the editor take effect before the site is rebuilt.
2311
1989
  */
2312
1990
  function installPreviewStyles(doc, options, styles) {
1991
+ doc.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, styles.theme.darkMode ? styles.scheme : "light");
2313
1992
  options.stylesheets.forEach((href, index) => {
2314
1993
  upsert(doc, `stylesheet-${index}`, "link", (link) => {
2315
1994
  link.rel = "stylesheet";
@@ -2351,19 +2030,25 @@ function preventNavigation(doc) {
2351
2030
  /** Applies preview styles to a document whenever the theme or custom CSS change, and keeps its media showing. */
2352
2031
  function usePreviewStyles(doc, styles) {
2353
2032
  const { preview, mediaPreviews } = useAdmin();
2033
+ const dark = useDarkMode();
2034
+ const scheme = styles.scheme ?? (dark ? "dark" : "light");
2354
2035
  useEffect(() => {
2355
2036
  if (!doc) return;
2356
2037
  preventNavigation(doc);
2357
- installPreviewStyles(doc, preview, styles);
2038
+ installPreviewStyles(doc, preview, {
2039
+ ...styles,
2040
+ scheme
2041
+ });
2358
2042
  }, [
2359
2043
  doc,
2360
2044
  preview,
2361
- styles
2045
+ styles,
2046
+ scheme
2362
2047
  ]);
2363
2048
  useEffect(() => doc ? showMediaInPreview(doc, mediaPreviews) : void 0, [doc, mediaPreviews]);
2364
2049
  }
2365
2050
  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>";
2051
+ 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
2052
  /**
2368
2053
  * A scaled-down, read-only view of a page as it will look on a desktop screen,
2369
2054
  * rendered into an isolated iframe so the site's styles and the admin panel's don't mix.
@@ -2398,7 +2083,7 @@ function PreviewFrame({ title, styles, children }) {
2398
2083
  className: "gfa-preview",
2399
2084
  children: [/* @__PURE__ */ jsx("iframe", {
2400
2085
  title,
2401
- srcDoc: EMPTY_DOCUMENT,
2086
+ srcDoc: EMPTY_DOCUMENT$1,
2402
2087
  onLoad: (event) => setDoc(event.currentTarget.contentDocument),
2403
2088
  style: {
2404
2089
  width: FRAME_WIDTH,
@@ -2461,7 +2146,7 @@ function restoreChange(content, subject) {
2461
2146
  case "entry": {
2462
2147
  const names = new Set(subject.collection.settings.fields.map((field) => field.name));
2463
2148
  const fields = Object.entries(subject.entry.content.fields).filter(([name]) => names.has(name));
2464
- return entryFileChange(subject.collection, subject.entry.slug, Object.fromEntries(fields));
2149
+ return entryFileChange$1(subject.collection, subject.entry.slug, Object.fromEntries(fields));
2465
2150
  }
2466
2151
  }
2467
2152
  }
@@ -2927,56 +2612,201 @@ function PublishFailure({ failure }) {
2927
2612
  });
2928
2613
  }
2929
2614
  //#endregion
2930
- //#region src/ai-panel.tsx
2931
- const usePuck$1 = createUsePuck();
2932
- const claudeModelLabels = {
2933
- "claude-opus-5-5": "ai.model.claude-opus-5-5",
2934
- "claude-sonnet-5-5": "ai.model.claude-sonnet-5-5",
2935
- "claude-haiku-4-5": "ai.model.claude-haiku-4-5"
2615
+ //#region src/upload.ts
2616
+ /** Photos are made no larger than this on their longest side, which is plenty for any screen. */
2617
+ const MAX_IMAGE_SIDE = 2400;
2618
+ var UploadError = class extends Error {
2619
+ problem;
2620
+ file;
2621
+ name = "UploadError";
2622
+ constructor(problem, file, message) {
2623
+ super(message);
2624
+ this.problem = problem;
2625
+ this.file = file;
2626
+ }
2936
2627
  };
2937
- function siteSummary(content, media) {
2938
- return {
2939
- title: content.settings.title,
2940
- description: content.settings.description,
2941
- language: content.settings.language,
2942
- pages: allPages(content).slice(0, 200).map((page) => {
2943
- const entry = (page.entry && content.collections.find((collection) => collection.id === page.entry?.collection))?.entries.find((candidate) => candidate.slug === page.entry?.slug);
2944
- return {
2945
- path: page.path,
2946
- title: entry ? entryTitle(entry) : pageTitle(page)
2947
- };
2948
- }),
2949
- collections: content.collections.map((collection) => ({
2950
- id: collection.id,
2951
- name: collection.settings.name,
2952
- fields: collection.settings.fields.map(({ name, label }) => ({
2953
- name,
2954
- label
2955
- }))
2956
- })),
2957
- images: siteImages(media),
2958
- contact: content.settings.contact
2959
- };
2960
- }
2961
- function newId(type) {
2962
- return `${type}-${crypto.randomUUID()}`;
2963
- }
2964
- function problemKey(error) {
2965
- if (error instanceof AiError) return `ai.error.${error.problem}`;
2966
- if (error instanceof SyntaxError) return "ai.error.answer";
2967
- return "ai.error.service";
2628
+ const REENCODED = {
2629
+ jpg: "image/jpeg",
2630
+ jpeg: "image/jpeg",
2631
+ png: "image/png",
2632
+ webp: "image/webp"
2633
+ };
2634
+ async function canvasBlob(canvas, type) {
2635
+ return new Promise((resolve) => canvas.toBlob(resolve, type, .85));
2968
2636
  }
2969
- function ServiceSettings({ providers, settings, onChange }) {
2970
- const t = useStrings();
2971
- const info = PROVIDERS[settings.provider];
2972
- const service = serviceName(settings.provider, t);
2973
- const [models, setModels] = useState(info.models ?? []);
2974
- const listId = useRef(`gfa-ai-models-${Math.random().toString(36).slice(2)}`).current;
2975
- const { provider, apiKey, baseUrl } = settings;
2976
- useEffect(() => {
2977
- let cancelled = false;
2978
- const controller = new AbortController();
2979
- setModels(PROVIDERS[provider].models ?? []);
2637
+ /**
2638
+ * Re-saves a photo: smaller if it's larger than any screen needs, and always
2639
+ * for JPEGs, which drops details hidden in the file such as where it was taken.
2640
+ * Returns `undefined` to keep the file as it is.
2641
+ */
2642
+ async function resized(file, extension) {
2643
+ const type = REENCODED[extension];
2644
+ if (!type || typeof createImageBitmap !== "function") return void 0;
2645
+ let bitmap;
2646
+ try {
2647
+ bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
2648
+ } catch {
2649
+ throw new UploadError("unreadable", file.name, `${file.name} couldn't be read as an image.`);
2650
+ }
2651
+ const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
2652
+ if (scale === 1 && type !== "image/jpeg") {
2653
+ bitmap.close();
2654
+ return;
2655
+ }
2656
+ const canvas = document.createElement("canvas");
2657
+ canvas.width = Math.round(bitmap.width * scale);
2658
+ canvas.height = Math.round(bitmap.height * scale);
2659
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
2660
+ bitmap.close();
2661
+ const blob = await canvasBlob(canvas, type);
2662
+ return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
2663
+ }
2664
+ const UNSAFE_SVG_ELEMENTS = [
2665
+ "script",
2666
+ "foreignObject",
2667
+ "iframe",
2668
+ "embed",
2669
+ "object",
2670
+ "audio",
2671
+ "video"
2672
+ ];
2673
+ /**
2674
+ * Removes anything from an SVG that could run code if someone opens the
2675
+ * file directly: scripts, embedded pages, event handlers, and javascript:, vbscript:
2676
+ * and data: links other than images.
2677
+ */
2678
+ function cleanSvg(text) {
2679
+ const doc = new DOMParser().parseFromString(text, "image/svg+xml");
2680
+ if (doc.querySelector("parsererror") || doc.documentElement.nodeName.toLowerCase() !== "svg") return void 0;
2681
+ for (const name of UNSAFE_SVG_ELEMENTS) for (const element of [...doc.getElementsByTagName(name)]) element.remove();
2682
+ for (const element of [doc.documentElement, ...doc.documentElement.querySelectorAll("*")]) for (const attribute of [...element.attributes]) {
2683
+ const value = attribute.value.trim().toLowerCase();
2684
+ if (attribute.name.toLowerCase().startsWith("on") || value.startsWith("javascript:") || value.startsWith("vbscript:") || value.startsWith("data:") && !value.startsWith("data:image/")) element.removeAttribute(attribute.name);
2685
+ }
2686
+ return new XMLSerializer().serializeToString(doc);
2687
+ }
2688
+ /** Checks a file chosen for upload and gets it ready: photos made web-sized, SVGs made safe. */
2689
+ async function prepareUpload(file) {
2690
+ const extension = extensionOf(file.name);
2691
+ if (!mediaKind(file.name)) throw new UploadError("type", file.name, `${file.name} isn't a kind of file the site can use.`);
2692
+ let blob = file;
2693
+ if (extension === "svg") {
2694
+ const cleaned = cleanSvg(await file.text());
2695
+ if (cleaned === void 0) throw new UploadError("unreadable", file.name, `${file.name} isn't a valid SVG image.`);
2696
+ blob = new Blob([cleaned], { type: "image/svg+xml" });
2697
+ } else blob = await resized(file, extension) ?? file;
2698
+ if (blob.size > 26214400) throw new UploadError("size", file.name, `${file.name} is ${blob.size} bytes, over the ${MAX_UPLOAD_BYTES}-byte limit.`);
2699
+ return {
2700
+ name: file.name,
2701
+ bytes: new Uint8Array(await blob.arrayBuffer()),
2702
+ type: blob.type || file.type
2703
+ };
2704
+ }
2705
+ //#endregion
2706
+ //#region src/stock-photos.ts
2707
+ /** The file types Openverse's downloads can come as that the media library takes. */
2708
+ const EXTENSIONS = {
2709
+ "image/jpeg": "jpg",
2710
+ "image/png": "png",
2711
+ "image/webp": "webp",
2712
+ "image/gif": "gif"
2713
+ };
2714
+ /**
2715
+ * Finds a photo for each of the AI's searches on Openverse, downloads it and
2716
+ * gets it ready to add to the media library under a name that isn't taken.
2717
+ * Searches past `MAX_STOCK_PHOTOS` find nothing.
2718
+ */
2719
+ async function findStockPhotos(queries, options) {
2720
+ const taken = [...options.taken];
2721
+ const photos = [];
2722
+ let problem;
2723
+ for (const query of queries.slice(0, MAX_STOCK_PHOTOS)) try {
2724
+ const photo = pickStockPhoto(await searchStockPhotos(query, options));
2725
+ if (!photo) continue;
2726
+ const blob = await downloadStockPhoto(photo, options);
2727
+ const extension = EXTENSIONS[blob.type.split(";")[0]?.trim().toLowerCase() ?? ""];
2728
+ if (!extension) continue;
2729
+ const name = uploadName(`${slugify(query).slice(0, 40) || "photo"}.${extension}`, taken);
2730
+ const prepared = await prepareUpload(new File([blob], name, { type: blob.type }));
2731
+ taken.push(name);
2732
+ photos.push({
2733
+ query,
2734
+ url: mediaUrl(`${MEDIA_DIR}/${name}`),
2735
+ bytes: prepared.bytes,
2736
+ credit: stockPhotoCredit(photo)
2737
+ });
2738
+ } catch (error) {
2739
+ if (options.signal?.aborted) throw error;
2740
+ if (error instanceof StockPhotoError) {
2741
+ problem = error;
2742
+ break;
2743
+ }
2744
+ }
2745
+ return {
2746
+ photos,
2747
+ missing: queries.length - photos.length,
2748
+ problem
2749
+ };
2750
+ }
2751
+ /** The media files for photos found, ready to publish with their credits (see `mediaFileChange()`). */
2752
+ function stockPhotoChanges(photos) {
2753
+ return photos.map((photo) => ({
2754
+ path: `${MEDIA_DIR}/${fileName(photo.url)}`,
2755
+ bytes: photo.bytes
2756
+ }));
2757
+ }
2758
+ //#endregion
2759
+ //#region src/ai-panel.tsx
2760
+ const usePuck$1 = createUsePuck();
2761
+ const claudeModelLabels = {
2762
+ "claude-opus-5-5": "ai.model.claude-opus-5-5",
2763
+ "claude-sonnet-5-5": "ai.model.claude-sonnet-5-5",
2764
+ "claude-haiku-4-5": "ai.model.claude-haiku-4-5"
2765
+ };
2766
+ function siteSummary(content, media, stockPhotos) {
2767
+ return {
2768
+ title: content.settings.title,
2769
+ description: content.settings.description,
2770
+ language: content.settings.language,
2771
+ pages: allPages(content).slice(0, 200).map((page) => {
2772
+ const entry = (page.entry && content.collections.find((collection) => collection.id === page.entry?.collection))?.entries.find((candidate) => candidate.slug === page.entry?.slug);
2773
+ return {
2774
+ path: page.path,
2775
+ title: entry ? entryTitle(entry) : pageTitle(page)
2776
+ };
2777
+ }),
2778
+ collections: content.collections.map((collection) => ({
2779
+ id: collection.id,
2780
+ name: collection.settings.name,
2781
+ fields: collection.settings.fields.map(({ name, label }) => ({
2782
+ name,
2783
+ label
2784
+ }))
2785
+ })),
2786
+ images: siteImages(media),
2787
+ contact: content.settings.contact,
2788
+ ...stockPhotos && { stockPhotos }
2789
+ };
2790
+ }
2791
+ function newId(type) {
2792
+ return `${type}-${crypto.randomUUID()}`;
2793
+ }
2794
+ function problemKey(error) {
2795
+ if (error instanceof AiError) return `ai.error.${error.problem}`;
2796
+ if (error instanceof SyntaxError) return "ai.error.answer";
2797
+ return "ai.error.service";
2798
+ }
2799
+ function ServiceSettings({ providers, settings, onChange }) {
2800
+ const t = useStrings();
2801
+ const info = PROVIDERS[settings.provider];
2802
+ const service = serviceName(settings.provider, t);
2803
+ const [models, setModels] = useState(info.models ?? []);
2804
+ const listId = useRef(`gfa-ai-models-${Math.random().toString(36).slice(2)}`).current;
2805
+ const { provider, apiKey, baseUrl } = settings;
2806
+ useEffect(() => {
2807
+ let cancelled = false;
2808
+ const controller = new AbortController();
2809
+ setModels(PROVIDERS[provider].models ?? []);
2980
2810
  const timer = setTimeout(() => {
2981
2811
  listModels({
2982
2812
  provider,
@@ -3089,12 +2919,15 @@ function serviceName(id, t) {
3089
2919
  /** The AI panel: describe what to write, and it's added to the page, replaces the selected block, or fills in the fields. */
3090
2920
  function AiPanel({ context }) {
3091
2921
  const t = useStrings();
3092
- const { config } = useAdmin();
2922
+ const { config, publish, mediaPreviews } = useAdmin();
3093
2923
  const { content, media } = useSiteContent();
3094
2924
  const getPuck = useGetPuck();
3095
2925
  const selected = usePuck$1((state) => state.selectedItem);
3096
2926
  const providers = useMemo(() => allowedProviders(config.ai ? config.ai.providers : void 0), [config.ai]);
3097
2927
  const [settings, updateSettings] = useAiSettings(providers);
2928
+ const [photosChosen, choosePhotos] = useStockPhotoChoice();
2929
+ const photosOffered = !(config.ai && config.ai.stockPhotos === false);
2930
+ const photosAllowed = photosOffered && photosChosen;
3098
2931
  const [instruction, setInstruction] = useState("");
3099
2932
  const [reply, setReply] = useState("");
3100
2933
  const [copied, setCopied] = useState(false);
@@ -3181,11 +3014,50 @@ function AiPanel({ context }) {
3181
3014
  })
3182
3015
  });
3183
3016
  };
3017
+ /**
3018
+ * Finds the photos the AI asked for, adds them to the media library with
3019
+ * their credits in one publish, and puts their addresses in the answer.
3020
+ * Fields whose photo wasn't found are left empty.
3021
+ */
3022
+ const addPhotos = async (answer, signal) => {
3023
+ const queries = photosAllowed ? stockPhotoQueries(answer) : [];
3024
+ if (queries.length === 0) return { answer: withStockPhotos(answer, /* @__PURE__ */ new Map()) };
3025
+ setStatus({ type: "photos" });
3026
+ const found = await findStockPhotos(queries, {
3027
+ taken: media.map(fileName),
3028
+ signal
3029
+ });
3030
+ let added = found.photos;
3031
+ let failure;
3032
+ if (added.length > 0 && !signal.aborted) {
3033
+ for (const photo of added) mediaPreviews.remember(photo.url, photo.bytes);
3034
+ const credits = {
3035
+ ...content.credits,
3036
+ ...Object.fromEntries(added.map((photo) => [photo.url, photo.credit]))
3037
+ };
3038
+ const result = await publish([...stockPhotoChanges(added), mediaFileChange(credits)], t("ai.photos.message", { names: added.map((photo) => fileName(photo.url)).join(", ") }));
3039
+ if (!result.ok) {
3040
+ failure = result.error;
3041
+ added = [];
3042
+ }
3043
+ }
3044
+ const photos = new Map(added.map((photo) => [photo.query, photo.url]));
3045
+ const report = {
3046
+ added: added.map((photo) => fileName(photo.url)),
3047
+ missing: queries.length - added.length,
3048
+ problem: found.problem,
3049
+ failure
3050
+ };
3051
+ return {
3052
+ answer: withStockPhotos(answer, photos),
3053
+ photos: report
3054
+ };
3055
+ };
3184
3056
  const run = async () => {
3185
3057
  const text = instruction.trim();
3186
3058
  if (!text) return;
3187
3059
  const task = taskFor(text);
3188
- const request = buildRequest(task, getPuck().config, siteSummary(content, media));
3060
+ const request = buildRequest(task, getPuck().config, siteSummary(content, media, photosAllowed));
3189
3061
  if (settings.provider === "manual") {
3190
3062
  setReply("");
3191
3063
  setCopied(false);
@@ -3212,8 +3084,13 @@ function AiPanel({ context }) {
3212
3084
  })
3213
3085
  });
3214
3086
  if (current.signal.aborted) return;
3215
- apply(task, answer);
3216
- setStatus({ type: "done" });
3087
+ const filled = await addPhotos(answer, current.signal);
3088
+ if (current.signal.aborted) return;
3089
+ apply(task, filled.answer);
3090
+ setStatus({
3091
+ type: "done",
3092
+ photos: filled.photos
3093
+ });
3217
3094
  } catch (error) {
3218
3095
  if (current.signal.aborted) setStatus({ type: "idle" });
3219
3096
  else setStatus({
@@ -3222,19 +3099,29 @@ function AiPanel({ context }) {
3222
3099
  });
3223
3100
  }
3224
3101
  };
3225
- const applyReply = () => {
3102
+ const applyReply = async () => {
3226
3103
  if (status.type !== "manual") return;
3104
+ controller.current?.abort();
3105
+ const current = new AbortController();
3106
+ controller.current = current;
3107
+ const { task } = status;
3227
3108
  try {
3228
- apply(status.task, parseAnswer(reply));
3229
- setStatus({ type: "done" });
3230
- } catch (error) {
3109
+ const filled = await addPhotos(parseAnswer(reply), current.signal);
3110
+ if (current.signal.aborted) return;
3111
+ apply(task, filled.answer);
3231
3112
  setStatus({
3113
+ type: "done",
3114
+ photos: filled.photos
3115
+ });
3116
+ } catch (error) {
3117
+ if (current.signal.aborted) setStatus({ type: "idle" });
3118
+ else setStatus({
3232
3119
  type: "failed",
3233
3120
  error
3234
3121
  });
3235
3122
  }
3236
3123
  };
3237
- const working = status.type === "working";
3124
+ const working = status.type === "working" || status.type === "photos";
3238
3125
  return /* @__PURE__ */ jsxs("div", {
3239
3126
  className: "gfa-ai",
3240
3127
  children: [
@@ -3257,6 +3144,17 @@ function AiPanel({ context }) {
3257
3144
  onChange: (event) => setInstruction(event.target.value)
3258
3145
  })
3259
3146
  }),
3147
+ photosOffered && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("label", {
3148
+ className: "gfa-checkbox",
3149
+ children: [/* @__PURE__ */ jsx("input", {
3150
+ type: "checkbox",
3151
+ checked: photosChosen,
3152
+ onChange: (event) => choosePhotos(event.target.checked)
3153
+ }), t("ai.photos")]
3154
+ }), /* @__PURE__ */ jsx("p", {
3155
+ className: "gfa-hint",
3156
+ children: t("ai.photosHint")
3157
+ })] }),
3260
3158
  !ready && /* @__PURE__ */ jsx("p", {
3261
3159
  className: "gfa-hint",
3262
3160
  children: t("ai.needsKey", { service })
@@ -3273,16 +3171,21 @@ function AiPanel({ context }) {
3273
3171
  children: t("ai.stop")
3274
3172
  })]
3275
3173
  }),
3276
- working && /* @__PURE__ */ jsx("p", {
3174
+ status.type === "working" && /* @__PURE__ */ jsx("p", {
3277
3175
  className: "gfa-notice",
3278
3176
  role: "status",
3279
3177
  children: status.characters > 0 ? t("ai.progress", { count: String(status.characters) }) : t("ai.working")
3280
3178
  }),
3281
- status.type === "done" && /* @__PURE__ */ jsx("p", {
3282
- className: "gfa-notice gfa-notice-success",
3179
+ status.type === "photos" && /* @__PURE__ */ jsx("p", {
3180
+ className: "gfa-notice",
3283
3181
  role: "status",
3284
- children: t("ai.done")
3182
+ children: t("ai.photos.finding")
3285
3183
  }),
3184
+ status.type === "done" && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("p", {
3185
+ className: "gfa-notice gfa-notice-success",
3186
+ role: "status",
3187
+ children: [t("ai.done"), status.photos?.added.length ? ` ${t("ai.photos.added", { names: status.photos.added.join(", ") })}` : null]
3188
+ }), status.photos && /* @__PURE__ */ jsx(PhotoProblems, { report: status.photos })] }),
3286
3189
  status.type === "failed" && /* @__PURE__ */ jsx(ErrorMessage, {
3287
3190
  message: t(problemKey(status.error), { service }),
3288
3191
  error: status.error
@@ -3317,7 +3220,7 @@ function AiPanel({ context }) {
3317
3220
  /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
3318
3221
  variant: "primary",
3319
3222
  disabled: !reply.trim(),
3320
- onClick: applyReply,
3223
+ onClick: () => void applyReply(),
3321
3224
  children: t("ai.manual.use")
3322
3225
  }) })
3323
3226
  ]
@@ -3339,6 +3242,23 @@ function AiPanel({ context }) {
3339
3242
  ]
3340
3243
  });
3341
3244
  }
3245
+ /** Why some of the photos the AI asked for weren't added, if any weren't. */
3246
+ function PhotoProblems({ report }) {
3247
+ const t = useStrings();
3248
+ if (report.failure) return /* @__PURE__ */ jsx(ErrorMessage, {
3249
+ message: t("ai.photos.error.publish"),
3250
+ error: report.failure
3251
+ });
3252
+ if (report.problem) return /* @__PURE__ */ jsx(ErrorMessage, {
3253
+ message: t(`ai.photos.error.${report.problem.problem}`),
3254
+ error: report.problem
3255
+ });
3256
+ if (report.missing > 0) return /* @__PURE__ */ jsx("p", {
3257
+ className: "gfa-notice",
3258
+ children: t("ai.photos.missing", { count: String(report.missing) })
3259
+ });
3260
+ return null;
3261
+ }
3342
3262
  function SparkleIcon() {
3343
3263
  return /* @__PURE__ */ jsxs("svg", {
3344
3264
  width: "20",
@@ -3363,98 +3283,7 @@ function aiPlugin(context, label) {
3363
3283
  name: "ai",
3364
3284
  label,
3365
3285
  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;
3418
- }
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);
3442
- }
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.`);
3454
- return {
3455
- name: file.name,
3456
- bytes: new Uint8Array(await blob.arrayBuffer()),
3457
- type: blob.type || file.type
3286
+ render: () => /* @__PURE__ */ jsx(AiPanel, { context })
3458
3287
  };
3459
3288
  }
3460
3289
  //#endregion
@@ -3515,7 +3344,7 @@ const uploadProblems = {
3515
3344
  function useUploadMedia() {
3516
3345
  const t = useStrings();
3517
3346
  const { publish, mediaPreviews } = useAdmin();
3518
- const { media } = useSiteContent();
3347
+ const { content, media } = useSiteContent();
3519
3348
  return async (files, replace) => {
3520
3349
  const taken = media.map(fileName);
3521
3350
  const changes = [];
@@ -3543,7 +3372,8 @@ function useUploadMedia() {
3543
3372
  }
3544
3373
  for (const upload of uploads) mediaPreviews.remember(upload.url, upload.bytes);
3545
3374
  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 }));
3375
+ const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
3376
+ const result = await publish(credits ? [...changes, credits] : changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }));
3547
3377
  if (!result.ok) return {
3548
3378
  ok: false,
3549
3379
  message: t(result.reason === "conflict" ? "publish.conflict" : "publish.error"),
@@ -3572,10 +3402,11 @@ function DeleteMediaDialog({ url, onClose }) {
3572
3402
  const path = mediaPath(url);
3573
3403
  if (!path) return;
3574
3404
  setBusy(true);
3405
+ const credits = withoutCredits(content.credits, [url]);
3575
3406
  const result = await publish([{
3576
3407
  path,
3577
3408
  delete: true
3578
- }], t("media.deleteMessage", { name }));
3409
+ }, ...credits ? [credits] : []], t("media.deleteMessage", { name }));
3579
3410
  setBusy(false);
3580
3411
  if (result.ok) onClose();
3581
3412
  else setFailure(result);
@@ -3602,6 +3433,21 @@ function DeleteMediaDialog({ url, onClose }) {
3602
3433
  ]
3603
3434
  });
3604
3435
  }
3436
+ /** Who made a photo and its license, which pages that use it show. */
3437
+ function MediaCredit({ credit }) {
3438
+ const t = useStrings();
3439
+ const text = [credit.creator, credit.license].filter(Boolean).join(" · ");
3440
+ return /* @__PURE__ */ jsx("span", {
3441
+ className: "gfa-media-credit",
3442
+ title: [credit.title, credit.source].filter(Boolean).join(" · "),
3443
+ children: credit.sourceUrl ? /* @__PURE__ */ jsx("a", {
3444
+ href: credit.sourceUrl,
3445
+ target: "_blank",
3446
+ rel: "noreferrer",
3447
+ children: t("media.credit", { credit: text })
3448
+ }) : t("media.credit", { credit: text })
3449
+ });
3450
+ }
3605
3451
  /** Lets files be dropped onto an area to upload them. */
3606
3452
  function DropZone({ onFiles, children }) {
3607
3453
  const t = useStrings();
@@ -3662,7 +3508,7 @@ function useUploads() {
3662
3508
  /** Every uploaded image and file, with buttons to upload, replace and delete them. */
3663
3509
  function MediaScreen() {
3664
3510
  const t = useStrings();
3665
- const { media } = useSiteContent();
3511
+ const { content, media } = useSiteContent();
3666
3512
  const { status, run } = useUploads();
3667
3513
  const [query, setQuery] = useState("");
3668
3514
  const [deleting, setDeleting] = useState(null);
@@ -3723,6 +3569,7 @@ function MediaScreen() {
3723
3569
  title: url,
3724
3570
  children: fileName(url)
3725
3571
  }),
3572
+ content.credits?.[url] && /* @__PURE__ */ jsx(MediaCredit, { credit: content.credits[url] }),
3726
3573
  /* @__PURE__ */ jsxs("div", {
3727
3574
  className: "gfa-media-actions",
3728
3575
  children: [
@@ -3961,7 +3808,8 @@ function TextFieldWithMedia({ field, value, onChange, readOnly, children }) {
3961
3808
  const ADMIN_PLACES = {
3962
3809
  settings: "#/settings/general",
3963
3810
  "contact-settings": `#/settings/general?section=${SETTINGS_SECTIONS.contact}`,
3964
- "forms-settings": `#/settings/general?section=${SETTINGS_SECTIONS.forms}`
3811
+ "forms-settings": `#/settings/general?section=${SETTINGS_SECTIONS.forms}`,
3812
+ "theme-settings": "#/settings/theme"
3965
3813
  };
3966
3814
  /** Where Puck remembers the sidebars' widths for every editor. */
3967
3815
  const PUCK_WIDTHS = "puck-sidebar-widths";
@@ -4270,7 +4118,8 @@ function NewEntryDialog({ collection, onClose }) {
4270
4118
  const [failure, setFailure] = useState(null);
4271
4119
  const { settings } = collection;
4272
4120
  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));
4121
+ const name = settings.path ? slug : uniqueName(slugify(title), collection.entries.map((existing) => existing.slug), slugify(settings.entryName));
4122
+ const check = checkEntrySlug(name, collection, allPages(content).map((page) => page.path));
4274
4123
  const titleError = submitted && !title.trim() ? t("field.required") : void 0;
4275
4124
  const slugError = submitted && !check.ok ? t(nameErrors[check.problem]) : void 0;
4276
4125
  const onSubmit = async (event) => {
@@ -4278,7 +4127,7 @@ function NewEntryDialog({ collection, onClose }) {
4278
4127
  setSubmitted(true);
4279
4128
  if (!title.trim() || !check.ok) return;
4280
4129
  setBusy(true);
4281
- const result = await publish([entryFileChange(collection, check.slug, { title: title.trim() })], t("newEntry.message", {
4130
+ const result = await publish([entryFileChange$1(collection, check.slug, { title: title.trim() })], t("newEntry.message", {
4282
4131
  entry,
4283
4132
  title: title.trim()
4284
4133
  }));
@@ -4587,7 +4436,7 @@ function TemplateScreen({ collection }) {
4587
4436
  collection,
4588
4437
  notice: /* @__PURE__ */ jsx(TemplateHelp, { collection }),
4589
4438
  toChanges: (data) => ({
4590
- changes: [collectionFileChange(collection.id, {
4439
+ changes: [collectionFileChange$1(collection.id, {
4591
4440
  ...settings,
4592
4441
  template: data
4593
4442
  })],
@@ -5287,7 +5136,7 @@ function NewCollectionDialog({ onClose }) {
5287
5136
  kind,
5288
5137
  blocks: Object.keys(config.blocks)
5289
5138
  });
5290
- const changes = [collectionFileChange(id, settings)];
5139
+ const changes = [collectionFileChange$1(id, settings)];
5291
5140
  if (setsTimeZone) changes.push(siteSettingsFileChange({
5292
5141
  ...content.settings,
5293
5142
  timeZone: ownTimeZone
@@ -5492,12 +5341,248 @@ function createDemoStore(siteUrl, base) {
5492
5341
  return demoStore(content, browserDemoStorage(new URL(`${root}/`, location.href).href));
5493
5342
  }
5494
5343
  //#endregion
5344
+ //#region src/sign-in.tsx
5345
+ function errorMessage(error) {
5346
+ if (error instanceof SignInError) return `signIn.error.${error.problem}`;
5347
+ return "signIn.error.other";
5348
+ }
5349
+ /** Problems with signing in by going to the host, which signing in with a token gets around. */
5350
+ const REDIRECT_PROBLEMS = /* @__PURE__ */ new Set([
5351
+ "not-installed",
5352
+ "no-address",
5353
+ "wrong-address",
5354
+ "local-address",
5355
+ "unavailable"
5356
+ ]);
5357
+ /**
5358
+ * Explains why signing in didn't work. Where Goodfellow isn't installed yet,
5359
+ * `onInstall` offers to install it, which signs the person in too; otherwise
5360
+ * it links to where it's fixed.
5361
+ */
5362
+ function SignInProblem({ error, host, expired, onInstall }) {
5363
+ const t = useStrings();
5364
+ const link = error instanceof SignInError ? error.link : void 0;
5365
+ const installable = onInstall && error instanceof SignInError && error.problem === "not-installed";
5366
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ErrorMessage, {
5367
+ message: expired && error instanceof SignInError && error.problem === "invalid" ? t("signIn.error.expired") : t(errorMessage(error), { host }),
5368
+ error
5369
+ }), installable ? /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
5370
+ variant: "primary",
5371
+ onClick: onInstall,
5372
+ children: t("signIn.install", { host })
5373
+ }) }) : link && /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx("a", {
5374
+ href: link,
5375
+ target: "_blank",
5376
+ rel: "noreferrer",
5377
+ children: t("signIn.install", { host })
5378
+ }) })] });
5379
+ }
5380
+ function SignInScreen({ host, error, onSignedIn }) {
5381
+ const t = useStrings();
5382
+ const [token, setToken] = useState("");
5383
+ const [remember, setRemember] = useState(false);
5384
+ const [busy, setBusy] = useState(false);
5385
+ const [failure, setFailure] = useState(error);
5386
+ const values = { host: host.name };
5387
+ const redirectFailed = failure instanceof SignInError && REDIRECT_PROBLEMS.has(failure.problem);
5388
+ const redirect = async (options) => {
5389
+ setBusy(true);
5390
+ setFailure(void 0);
5391
+ try {
5392
+ await host.startRedirect(remember, options);
5393
+ } catch (caught) {
5394
+ setFailure(caught);
5395
+ setBusy(false);
5396
+ }
5397
+ };
5398
+ const onSubmit = async (event) => {
5399
+ event.preventDefault();
5400
+ setBusy(true);
5401
+ setFailure(void 0);
5402
+ try {
5403
+ onSignedIn(await host.signInWithToken(token, remember));
5404
+ } catch (caught) {
5405
+ setFailure(caught);
5406
+ setBusy(false);
5407
+ }
5408
+ };
5409
+ const remembered = /* @__PURE__ */ jsxs("label", {
5410
+ className: "gfa-checkbox",
5411
+ children: [/* @__PURE__ */ jsx("input", {
5412
+ type: "checkbox",
5413
+ checked: remember,
5414
+ onChange: (event) => setRemember(event.target.checked)
5415
+ }), /* @__PURE__ */ jsxs("span", { children: [t("signIn.remember"), /* @__PURE__ */ jsx("span", {
5416
+ className: "gfa-hint gfa-block",
5417
+ children: t("signIn.rememberHint")
5418
+ })] })]
5419
+ });
5420
+ return /* @__PURE__ */ jsxs("div", {
5421
+ className: "gfa-sign-in",
5422
+ children: [
5423
+ /* @__PURE__ */ jsx("h1", { children: t("signIn.title") }),
5424
+ /* @__PURE__ */ jsx("p", { children: t("signIn.intro", values) }),
5425
+ failure !== void 0 && /* @__PURE__ */ jsx(SignInProblem, {
5426
+ error: failure,
5427
+ host: host.name,
5428
+ expired: error === failure,
5429
+ onInstall: () => void redirect({ install: true })
5430
+ }),
5431
+ host.canRedirect && /* @__PURE__ */ jsxs("div", {
5432
+ className: "gfa-form",
5433
+ children: [remembered, /* @__PURE__ */ jsx(Button, {
5434
+ variant: "primary",
5435
+ disabled: busy,
5436
+ onClick: () => void redirect(),
5437
+ children: t("signIn.redirect", values)
5438
+ })]
5439
+ }),
5440
+ host.tokenLinks && host.tokenLinks.length > 0 && /* @__PURE__ */ jsxs("details", {
5441
+ className: "gfa-sign-in-token",
5442
+ open: !host.canRedirect || redirectFailed,
5443
+ children: [/* @__PURE__ */ jsx("summary", { children: t(host.canRedirect ? "signIn.orToken" : "signIn.token.title") }), /* @__PURE__ */ jsxs("form", {
5444
+ className: "gfa-form",
5445
+ onSubmit,
5446
+ children: [
5447
+ /* @__PURE__ */ jsxs("ol", {
5448
+ className: "gfa-steps",
5449
+ children: [/* @__PURE__ */ jsxs("li", { children: [
5450
+ /* @__PURE__ */ jsx("p", { children: t("signIn.token.step1", values) }),
5451
+ /* @__PURE__ */ jsx("ul", {
5452
+ className: "gfa-token-links",
5453
+ children: host.tokenLinks.map((link) => /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx("a", {
5454
+ href: link.url,
5455
+ target: "_blank",
5456
+ rel: "noreferrer",
5457
+ children: t(link.label, values)
5458
+ }), link.hint && /* @__PURE__ */ jsx("p", {
5459
+ className: "gfa-hint",
5460
+ children: t(link.hint, values)
5461
+ })] }, link.url))
5462
+ }),
5463
+ /* @__PURE__ */ jsx("p", {
5464
+ className: "gfa-hint",
5465
+ children: t("signIn.token.chooseSite", values)
5466
+ })
5467
+ ] }), /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx("p", { children: t("signIn.token.step2") }), /* @__PURE__ */ jsx(TextField, {
5468
+ label: t("signIn.token.label"),
5469
+ type: "password",
5470
+ autoComplete: "off",
5471
+ spellCheck: false,
5472
+ value: token,
5473
+ onChange: setToken
5474
+ })] })]
5475
+ }),
5476
+ !host.canRedirect && remembered,
5477
+ /* @__PURE__ */ jsx(Button, {
5478
+ type: "submit",
5479
+ variant: "primary",
5480
+ disabled: busy || !token.trim(),
5481
+ children: busy ? t("signIn.checking") : t("signIn.submit")
5482
+ })
5483
+ ]
5484
+ })]
5485
+ })
5486
+ ]
5487
+ });
5488
+ }
5489
+ /**
5490
+ * Shows the sign-in screen until someone is signed in to the git host, then
5491
+ * renders its children with the connected backend.
5492
+ */
5493
+ function SignInGate({ host, children }) {
5494
+ const t = useStrings();
5495
+ const [state, setState] = useState({ status: "checking" });
5496
+ useEffect(() => {
5497
+ let cancelled = false;
5498
+ host.restore().then((backend) => !cancelled && setState(backend ? {
5499
+ status: "signed-in",
5500
+ backend
5501
+ } : { status: "signed-out" }), (error) => !cancelled && setState({
5502
+ status: "signed-out",
5503
+ error
5504
+ }));
5505
+ return () => {
5506
+ cancelled = true;
5507
+ };
5508
+ }, [host]);
5509
+ if (state.status === "checking") return /* @__PURE__ */ jsx("p", {
5510
+ className: "gfa-screen",
5511
+ children: t("signIn.checking")
5512
+ });
5513
+ if (state.status === "signed-out") return /* @__PURE__ */ jsx(SignInScreen, {
5514
+ host,
5515
+ error: state.error,
5516
+ onSignedIn: (backend) => setState({
5517
+ status: "signed-in",
5518
+ backend
5519
+ })
5520
+ });
5521
+ const { backend } = state;
5522
+ return children(backend, (error) => {
5523
+ backend.signOut();
5524
+ setState({
5525
+ status: "signed-out",
5526
+ error
5527
+ });
5528
+ });
5529
+ }
5530
+ //#endregion
5495
5531
  //#region src/owner-token.tsx
5496
5532
  /**
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.
5533
+ * Asks an owner to sign in for changing editors and Pages settings, which
5534
+ * everyday sign-in can't, where the host can, with the owner token form below.
5535
+ * It's used for this tab only.
5499
5536
  */
5500
5537
  function OwnerTokenForm({ onReady }) {
5538
+ const t = useStrings();
5539
+ const { ownerAccess, account } = useAdmin();
5540
+ const host = account?.hostName ?? "";
5541
+ const [busy, setBusy] = useState(false);
5542
+ const [error, setError] = useState(ownerAccess?.problem);
5543
+ if (!ownerAccess) return null;
5544
+ if (!ownerAccess.startRedirect) return /* @__PURE__ */ jsx(OwnerTokenFields, { onReady });
5545
+ const signIn = async (options) => {
5546
+ setBusy(true);
5547
+ setError(void 0);
5548
+ try {
5549
+ await ownerAccess.startRedirect?.(options);
5550
+ } catch (caught) {
5551
+ setError(caught);
5552
+ setBusy(false);
5553
+ }
5554
+ };
5555
+ return /* @__PURE__ */ jsxs("section", {
5556
+ className: "gfa-form gfa-owner-token",
5557
+ children: [
5558
+ /* @__PURE__ */ jsx("h2", {
5559
+ className: "gfa-section-title",
5560
+ children: t("owner.signIn.title")
5561
+ }),
5562
+ /* @__PURE__ */ jsx("p", { children: t("owner.signIn.intro", { host }) }),
5563
+ error !== void 0 && /* @__PURE__ */ jsx(SignInProblem, {
5564
+ error,
5565
+ host,
5566
+ onInstall: () => void signIn({ install: true })
5567
+ }),
5568
+ /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
5569
+ variant: "primary",
5570
+ disabled: busy,
5571
+ onClick: () => void signIn(),
5572
+ children: t("owner.signIn.button", { host })
5573
+ }) }),
5574
+ /* @__PURE__ */ jsxs("details", {
5575
+ className: "gfa-sign-in-token",
5576
+ children: [/* @__PURE__ */ jsx("summary", { children: t("owner.signIn.orToken") }), /* @__PURE__ */ jsx(OwnerTokenFields, {
5577
+ onReady,
5578
+ heading: false
5579
+ })]
5580
+ })
5581
+ ]
5582
+ });
5583
+ }
5584
+ /** The owner token form: where to create one, and a field to paste it into. */
5585
+ function OwnerTokenFields({ onReady, heading = true }) {
5501
5586
  const t = useStrings();
5502
5587
  const { ownerAccess, account } = useAdmin();
5503
5588
  const host = account?.hostName ?? "";
@@ -5519,9 +5604,9 @@ function OwnerTokenForm({ onReady }) {
5519
5604
  }
5520
5605
  };
5521
5606
  return /* @__PURE__ */ jsxs("section", {
5522
- className: "gfa-form gfa-owner-token",
5607
+ className: heading ? "gfa-form gfa-owner-token" : "gfa-form",
5523
5608
  children: [
5524
- /* @__PURE__ */ jsx("h2", {
5609
+ heading && /* @__PURE__ */ jsx("h2", {
5525
5610
  className: "gfa-section-title",
5526
5611
  children: t("owner.token.title")
5527
5612
  }),
@@ -5580,69 +5665,7 @@ function OwnerTokenActive({ onForget }) {
5580
5665
  children: t("owner.token.forget")
5581
5666
  })
5582
5667
  ]
5583
- });
5584
- }
5585
- //#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);
5668
+ });
5646
5669
  }
5647
5670
  //#endregion
5648
5671
  //#region src/code-editor.tsx
@@ -5933,7 +5956,7 @@ function FontPicker({ label, value, error, onChange }) {
5933
5956
  setQuery("");
5934
5957
  };
5935
5958
  const pick = (option) => {
5936
- onChange(option ? option[0] : "");
5959
+ onChange(option ? option[0] : "", option?.[1]);
5937
5960
  close();
5938
5961
  };
5939
5962
  const openList = () => {
@@ -6502,7 +6525,10 @@ function headCodeProblem(t, head) {
6502
6525
  /** Turns the form's values into settings: empty optional fields are left out rather than saved as "". */
6503
6526
  function cleanSettings(draft) {
6504
6527
  const optional = (value) => value?.trim() ? value.trim() : void 0;
6505
- const colors = Object.fromEntries(Object.entries(draft.theme.colors).filter(([, value]) => value?.trim()));
6528
+ const filled = (colors) => Object.fromEntries(Object.entries(colors ?? {}).filter(([, value]) => value?.trim()));
6529
+ const colors = filled(draft.theme.colors);
6530
+ const darkColors = filled(draft.theme.darkColors);
6531
+ const darkLogo = optional(draft.logo?.darkSrc);
6506
6532
  return {
6507
6533
  ...draft,
6508
6534
  url: optional(draft.url),
@@ -6512,20 +6538,40 @@ function cleanSettings(draft) {
6512
6538
  socialImage: optional(draft.socialImage),
6513
6539
  logo: draft.logo?.src.trim() ? {
6514
6540
  src: draft.logo.src.trim(),
6515
- alt: draft.logo.alt
6541
+ alt: draft.logo.alt,
6542
+ ...darkLogo && { darkSrc: darkLogo }
6516
6543
  } : void 0,
6517
6544
  contact: cleanContact(draft.contact),
6518
6545
  forms: cleanForms(draft.forms),
6519
6546
  theme: {
6520
6547
  colors,
6521
- fonts: {
6522
- heading: optional(draft.theme.fonts.heading),
6523
- body: optional(draft.theme.fonts.body)
6524
- },
6525
- radius: optional(draft.theme.radius)
6548
+ fonts: cleanFonts(draft.theme.fonts),
6549
+ radius: optional(draft.theme.radius),
6550
+ darkMode: draft.theme.darkMode || void 0,
6551
+ darkColors: Object.keys(darkColors).length > 0 ? darkColors : void 0,
6552
+ code: cleanCode(draft.theme.code)
6526
6553
  }
6527
6554
  };
6528
6555
  }
6556
+ /** A font's kind is kept only with the font, which is all it's about. */
6557
+ function cleanFonts(fonts) {
6558
+ const heading = fonts.heading?.trim() || void 0;
6559
+ const body = fonts.body?.trim() || void 0;
6560
+ return {
6561
+ heading,
6562
+ ...heading && fonts.headingCategory && { headingCategory: fonts.headingCategory },
6563
+ body,
6564
+ ...body && fonts.bodyCategory && { bodyCategory: fonts.bodyCategory }
6565
+ };
6566
+ }
6567
+ function cleanCode(code) {
6568
+ const cleaned = {
6569
+ ...code?.light && { light: code.light },
6570
+ ...code?.dark && { dark: code.dark },
6571
+ ...code?.background === "site" && { background: "site" }
6572
+ };
6573
+ return Object.keys(cleaned).length > 0 ? cleaned : void 0;
6574
+ }
6529
6575
  function cleanContact(contact) {
6530
6576
  const entries = Object.entries(contact ?? {}).flatMap(([key, value]) => value?.trim() ? [[key, value.trim()]] : []);
6531
6577
  return entries.length > 0 ? Object.fromEntries(entries) : void 0;
@@ -6743,6 +6789,7 @@ function GeneralTab({ draft, errors, formsError, onChange }) {
6743
6789
  onChange: (src) => onChange({
6744
6790
  ...draft,
6745
6791
  logo: {
6792
+ ...draft.logo,
6746
6793
  alt: draft.logo?.alt ?? "",
6747
6794
  src
6748
6795
  }
@@ -6754,11 +6801,25 @@ function GeneralTab({ draft, errors, formsError, onChange }) {
6754
6801
  onChange: (alt) => onChange({
6755
6802
  ...draft,
6756
6803
  logo: {
6804
+ ...draft.logo,
6757
6805
  src: draft.logo?.src ?? "",
6758
6806
  alt
6759
6807
  }
6760
6808
  })
6761
6809
  }),
6810
+ draft.theme.darkMode && /* @__PURE__ */ jsx(MediaField, {
6811
+ label: t("general.logoDark"),
6812
+ hint: t("general.logoDarkHint"),
6813
+ value: draft.logo?.darkSrc ?? "",
6814
+ onChange: (darkSrc) => onChange({
6815
+ ...draft,
6816
+ logo: {
6817
+ src: draft.logo?.src ?? "",
6818
+ alt: draft.logo?.alt ?? "",
6819
+ darkSrc
6820
+ }
6821
+ })
6822
+ }),
6762
6823
  /* @__PURE__ */ jsx(MediaField, {
6763
6824
  label: t("general.favicon"),
6764
6825
  ...text("favicon")
@@ -6829,7 +6890,37 @@ function GeneralTab({ draft, errors, formsError, onChange }) {
6829
6890
  });
6830
6891
  }
6831
6892
  const HEX = /^#[0-9a-f]{6}$/i;
6832
- function ThemeTab({ draft, errors, onChange }) {
6893
+ /** One set of the theme's colors: the light ones, or dark mode's. */
6894
+ function ColorGrid({ colors, defaults, errors, errorPrefix, onChange }) {
6895
+ const t = useStrings();
6896
+ return /* @__PURE__ */ jsx("div", {
6897
+ className: "gfa-color-grid",
6898
+ children: THEME_COLORS.map((name) => {
6899
+ const value = colors[name] ?? "";
6900
+ const shown = value || defaults[name];
6901
+ return /* @__PURE__ */ jsx(Field, {
6902
+ label: t(`theme.color.${name}`),
6903
+ error: errors[`${errorPrefix}.${name}`],
6904
+ children: (props) => /* @__PURE__ */ jsxs("div", {
6905
+ className: "gfa-color-input",
6906
+ children: [/* @__PURE__ */ jsx("input", {
6907
+ type: "color",
6908
+ "aria-label": t("theme.pickColor", { name: t(`theme.color.${name}`) }),
6909
+ value: HEX.test(shown) ? shown : "#000000",
6910
+ onChange: (event) => onChange(name, event.target.value)
6911
+ }), /* @__PURE__ */ jsx("input", {
6912
+ ...props,
6913
+ className: "gfa-input",
6914
+ value,
6915
+ placeholder: `${t("theme.default")}: ${defaults[name]}`,
6916
+ onChange: (event) => onChange(name, event.target.value)
6917
+ })]
6918
+ })
6919
+ }, name);
6920
+ })
6921
+ });
6922
+ }
6923
+ function ThemeTab({ draft, errors, previewScheme, onChange, onPreviewScheme }) {
6833
6924
  const t = useStrings();
6834
6925
  const { theme } = draft;
6835
6926
  const setColor = (name, value) => onChange({
@@ -6842,6 +6933,16 @@ function ThemeTab({ draft, errors, onChange }) {
6842
6933
  }
6843
6934
  }
6844
6935
  });
6936
+ const setDarkColor = (name, value) => onChange({
6937
+ ...draft,
6938
+ theme: {
6939
+ ...theme,
6940
+ darkColors: {
6941
+ ...theme.darkColors,
6942
+ [name]: value
6943
+ }
6944
+ }
6945
+ });
6845
6946
  return /* @__PURE__ */ jsxs("div", {
6846
6947
  className: "gfa-form",
6847
6948
  children: [
@@ -6849,31 +6950,112 @@ function ThemeTab({ draft, errors, onChange }) {
6849
6950
  className: "gfa-section-title",
6850
6951
  children: t("theme.colors")
6851
6952
  }),
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);
6953
+ /* @__PURE__ */ jsx(ColorGrid, {
6954
+ colors: theme.colors,
6955
+ defaults: DEFAULT_THEME_COLORS,
6956
+ errors,
6957
+ errorPrefix: "theme.colors",
6958
+ onChange: setColor
6959
+ }),
6960
+ /* @__PURE__ */ jsx("h2", {
6961
+ className: "gfa-section-title",
6962
+ children: t("theme.darkMode")
6963
+ }),
6964
+ /* @__PURE__ */ jsxs("label", {
6965
+ className: "gfa-checkbox",
6966
+ children: [/* @__PURE__ */ jsx("input", {
6967
+ type: "checkbox",
6968
+ checked: theme.darkMode === true,
6969
+ onChange: (event) => onChange({
6970
+ ...draft,
6971
+ theme: {
6972
+ ...theme,
6973
+ darkMode: event.target.checked
6974
+ }
6975
+ })
6976
+ }), t("theme.darkModeOn")]
6977
+ }),
6978
+ /* @__PURE__ */ jsx("p", {
6979
+ className: "gfa-hint",
6980
+ children: t("theme.darkModeHint")
6981
+ }),
6982
+ theme.darkMode && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("fieldset", {
6983
+ className: "gfa-choice-group",
6984
+ children: [/* @__PURE__ */ jsx("legend", {
6985
+ className: "gfa-label",
6986
+ children: t("theme.preview")
6987
+ }), ["light", "dark"].map((scheme) => /* @__PURE__ */ jsxs("label", {
6988
+ className: "gfa-checkbox",
6989
+ children: [/* @__PURE__ */ jsx("input", {
6990
+ type: "radio",
6991
+ name: "preview-scheme",
6992
+ checked: previewScheme === scheme,
6993
+ onChange: () => onPreviewScheme(scheme)
6994
+ }), t(scheme === "light" ? "theme.previewLight" : "theme.previewDark")]
6995
+ }, scheme))]
6996
+ }), /* @__PURE__ */ jsxs("fieldset", {
6997
+ className: "gfa-choice-group",
6998
+ children: [/* @__PURE__ */ jsx("legend", {
6999
+ className: "gfa-label",
7000
+ children: t("theme.darkColors")
7001
+ }), /* @__PURE__ */ jsx(ColorGrid, {
7002
+ colors: theme.darkColors ?? {},
7003
+ defaults: DEFAULT_DARK_THEME_COLORS,
7004
+ errors,
7005
+ errorPrefix: "theme.darkColors",
7006
+ onChange: setDarkColor
7007
+ })]
7008
+ })] }),
7009
+ /* @__PURE__ */ jsx("h2", {
7010
+ className: "gfa-section-title",
7011
+ children: t("theme.code")
7012
+ }),
7013
+ /* @__PURE__ */ jsx("p", {
7014
+ className: "gfa-hint",
7015
+ children: t("theme.codeHint")
7016
+ }),
7017
+ ["light", "dark"].map((side) => /* @__PURE__ */ jsx(Field, {
7018
+ label: t(side === "light" ? "theme.codeLight" : "theme.codeDark"),
7019
+ children: (props) => /* @__PURE__ */ jsx("select", {
7020
+ ...props,
7021
+ className: "gfa-input",
7022
+ value: theme.code?.[side] ?? DEFAULT_CODE_THEMES[side],
7023
+ onChange: (event) => onChange({
7024
+ ...draft,
7025
+ theme: {
7026
+ ...theme,
7027
+ code: {
7028
+ ...theme.code,
7029
+ [side]: event.target.value
7030
+ }
7031
+ }
7032
+ }),
7033
+ children: Object.entries(CODE_THEMES[side]).map(([name, label]) => /* @__PURE__ */ jsx("option", {
7034
+ value: name,
7035
+ children: label
7036
+ }, name))
6876
7037
  })
7038
+ }, side)),
7039
+ /* @__PURE__ */ jsxs("label", {
7040
+ className: "gfa-checkbox",
7041
+ children: [/* @__PURE__ */ jsx("input", {
7042
+ type: "checkbox",
7043
+ checked: theme.code?.background === "site",
7044
+ onChange: (event) => onChange({
7045
+ ...draft,
7046
+ theme: {
7047
+ ...theme,
7048
+ code: {
7049
+ ...theme.code,
7050
+ background: event.target.checked ? "site" : void 0
7051
+ }
7052
+ }
7053
+ })
7054
+ }), t("theme.codeBackground")]
7055
+ }),
7056
+ /* @__PURE__ */ jsx("p", {
7057
+ className: "gfa-hint",
7058
+ children: t("theme.codeBackgroundHint")
6877
7059
  }),
6878
7060
  /* @__PURE__ */ jsx("h2", {
6879
7061
  className: "gfa-section-title",
@@ -6887,13 +7069,14 @@ function ThemeTab({ draft, errors, onChange }) {
6887
7069
  label: t("theme.headingFont"),
6888
7070
  value: theme.fonts.heading ?? "",
6889
7071
  error: errors["theme.fonts.heading"],
6890
- onChange: (heading) => onChange({
7072
+ onChange: (heading, headingCategory) => onChange({
6891
7073
  ...draft,
6892
7074
  theme: {
6893
7075
  ...theme,
6894
7076
  fonts: {
6895
7077
  ...theme.fonts,
6896
- heading
7078
+ heading,
7079
+ headingCategory
6897
7080
  }
6898
7081
  }
6899
7082
  })
@@ -6902,13 +7085,14 @@ function ThemeTab({ draft, errors, onChange }) {
6902
7085
  label: t("theme.bodyFont"),
6903
7086
  value: theme.fonts.body ?? "",
6904
7087
  error: errors["theme.fonts.body"],
6905
- onChange: (body) => onChange({
7088
+ onChange: (body, bodyCategory) => onChange({
6906
7089
  ...draft,
6907
7090
  theme: {
6908
7091
  ...theme,
6909
7092
  fonts: {
6910
7093
  ...theme.fonts,
6911
- body
7094
+ body,
7095
+ bodyCategory
6912
7096
  }
6913
7097
  }
6914
7098
  })
@@ -7154,10 +7338,34 @@ function SettingsScreen({ tab, section }) {
7154
7338
  content.collections,
7155
7339
  homeView
7156
7340
  ]);
7341
+ const [resolvedHome, setResolvedHome] = useState();
7342
+ useEffect(() => {
7343
+ if (!home) return;
7344
+ let current = true;
7345
+ const timer = setTimeout(() => {
7346
+ resolveAllData(home.content.data, pageConfig, siteMetadata(previewSite)).then((data) => current && setResolvedHome(data), () => {});
7347
+ }, 300);
7348
+ return () => {
7349
+ current = false;
7350
+ clearTimeout(timer);
7351
+ };
7352
+ }, [
7353
+ home,
7354
+ pageConfig,
7355
+ previewSite
7356
+ ]);
7357
+ const adminDark = useDarkMode();
7358
+ const [chosenScheme, setChosenScheme] = useState();
7359
+ const previewScheme = chosenScheme ?? (adminDark ? "dark" : "light");
7157
7360
  const previewStyles = useMemo(() => ({
7158
7361
  theme: previewSettings.theme,
7159
- customCss: css
7160
- }), [previewSettings, css]);
7362
+ customCss: css,
7363
+ scheme: previewScheme
7364
+ }), [
7365
+ previewSettings,
7366
+ css,
7367
+ previewScheme
7368
+ ]);
7161
7369
  const errors = showErrors ? validation.errors : {};
7162
7370
  return /* @__PURE__ */ jsxs("div", {
7163
7371
  className: "gfa-split",
@@ -7212,7 +7420,9 @@ function SettingsScreen({ tab, section }) {
7212
7420
  tab === "theme" && /* @__PURE__ */ jsx(ThemeTab, {
7213
7421
  draft: settings,
7214
7422
  errors,
7215
- onChange: setSettings
7423
+ previewScheme,
7424
+ onChange: setSettings,
7425
+ onPreviewScheme: setChosenScheme
7216
7426
  }),
7217
7427
  tab === "menus" && /* @__PURE__ */ jsx(MenusEditor, {
7218
7428
  menus,
@@ -7249,7 +7459,7 @@ function SettingsScreen({ tab, section }) {
7249
7459
  site: previewSite,
7250
7460
  pageConfig,
7251
7461
  layoutConfig,
7252
- page: home.content.data,
7462
+ page: resolvedHome ?? home.content.data,
7253
7463
  header: content.header.data,
7254
7464
  footer: content.footer.data
7255
7465
  })
@@ -7534,7 +7744,7 @@ function ConnectedDomain({ domain, onRemoved }) {
7534
7744
  className: "gfa-form",
7535
7745
  children: [
7536
7746
  /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx("strong", { children: t("domain.connected", { domain }) }) }),
7537
- account?.canRedirect && /* @__PURE__ */ jsx("p", {
7747
+ account?.redirectAddressesListed && /* @__PURE__ */ jsx("p", {
7538
7748
  className: "gfa-notice",
7539
7749
  children: t("domain.signIn", {
7540
7750
  host,
@@ -8636,7 +8846,7 @@ function EntryEditor({ collection, entry }) {
8636
8846
  const values = Object.fromEntries(Object.entries(next.root.props ?? {}).filter(([name]) => name !== "id"));
8637
8847
  const title = typeof values.title === "string" && values.title.trim() ? values.title.trim() : entry.slug;
8638
8848
  return {
8639
- changes: [entryFileChange(collection, entry.slug, values)],
8849
+ changes: [entryFileChange$1(collection, entry.slug, values)],
8640
8850
  message: t("editEntry.message", {
8641
8851
  entry: entryName,
8642
8852
  title
@@ -8948,147 +9158,108 @@ function PagesScreen() {
8948
9158
  });
8949
9159
  }
8950
9160
  //#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";
9161
+ //#region src/site-preview.tsx
9162
+ /**
9163
+ * The page tools such as the Claude connector take screenshots of drafts with.
9164
+ * At `/admin/#/preview`, the admin panel shows nothing and signs nobody in
9165
+ * until a script in the page calls `window.goodfellowPreview()` with a draft's
9166
+ * content files and an address; it then shows that page as the live site
9167
+ * would. Only code already running in the page can call it, so no link and no
9168
+ * other site can put content here.
9169
+ */
9170
+ /** The admin panel's address for the preview page. */
9171
+ const PREVIEW_ROUTE = "#/preview";
9172
+ /** Whether the admin panel was opened at the preview page. */
9173
+ function isPreviewAddress(hash) {
9174
+ return hash === PREVIEW_ROUTE;
8955
9175
  }
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
- }
9176
+ function filesSource(files) {
9177
+ return {
9178
+ read: async (path) => Object.hasOwn(files, path) ? files[path] : void 0,
9179
+ list: async (dir) => Object.keys(files).filter((path) => path.startsWith(`${dir}/`)).sort()
8973
9180
  };
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
9181
  }
9182
+ 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
9183
  /**
9053
- * Shows the sign-in screen until someone is signed in to the git host, then
9054
- * renders its children with the connected backend.
9184
+ * Shows a page from content handed to `window.goodfellowPreview()`, full width,
9185
+ * in an iframe as tall as the page, so the site's styles and the admin panel's
9186
+ * don't mix and a full-page screenshot shows all of it.
9055
9187
  */
9056
- function SignInGate({ host, children }) {
9057
- const t = useStrings();
9058
- const [state, setState] = useState({ status: "checking" });
9188
+ function SitePreview({ config, preview }) {
9189
+ const configs = useMemo(() => createPuckConfigs(config), [config]);
9190
+ const [doc, setDoc] = useState(null);
9191
+ const [shown, setShown] = useState();
9192
+ const [height, setHeight] = useState(800);
9193
+ const rendered = useRef(void 0);
9059
9194
  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
- }));
9195
+ window.goodfellowPreview = async ({ files, path, dark }) => {
9196
+ let content;
9197
+ try {
9198
+ content = await loadSiteContent(filesSource(files));
9199
+ } catch (error) {
9200
+ return {
9201
+ ok: false,
9202
+ problem: error.message
9203
+ };
9204
+ }
9205
+ const match = findSitePage(content, todayIn(content.settings.timeZone), config.blocks, path);
9206
+ if (!match) return {
9207
+ ok: false,
9208
+ problem: `The site has no page at ${path}.`
9209
+ };
9210
+ const prepared = await preparePage(configs, content, match.page);
9211
+ await new Promise((done) => {
9212
+ rendered.current = done;
9213
+ setShown({
9214
+ content,
9215
+ prepared,
9216
+ dark: dark === true
9217
+ });
9218
+ });
9219
+ return {
9220
+ ok: true,
9221
+ status: match.status
9222
+ };
9223
+ };
9068
9224
  return () => {
9069
- cancelled = true;
9225
+ delete window.goodfellowPreview;
9070
9226
  };
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
9227
+ }, [config.blocks, configs]);
9228
+ useEffect(() => {
9229
+ if (!doc || !shown) return;
9230
+ installPreviewStyles(doc, preview, {
9231
+ theme: shown.content.settings.theme,
9232
+ customCss: shown.content.customCss,
9233
+ scheme: shown.dark ? "dark" : "light"
9090
9234
  });
9091
- });
9235
+ requestAnimationFrame(() => requestAnimationFrame(() => {
9236
+ setHeight(Math.max(doc.documentElement.scrollHeight, 200));
9237
+ rendered.current?.();
9238
+ rendered.current = void 0;
9239
+ }));
9240
+ }, [
9241
+ doc,
9242
+ shown,
9243
+ preview
9244
+ ]);
9245
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("iframe", {
9246
+ title: "Preview",
9247
+ srcDoc: EMPTY_DOCUMENT,
9248
+ onLoad: (event) => setDoc(event.currentTarget.contentDocument),
9249
+ style: {
9250
+ display: "block",
9251
+ width: "100%",
9252
+ height,
9253
+ border: 0
9254
+ }
9255
+ }), doc && shown && createPortal(/* @__PURE__ */ jsx(PageBody, {
9256
+ site: shown.prepared.site,
9257
+ pageConfig: shown.prepared.pageConfig,
9258
+ layoutConfig: shown.prepared.layoutConfig,
9259
+ page: shown.prepared.data,
9260
+ header: shown.prepared.header,
9261
+ footer: shown.prepared.footer
9262
+ }), doc.body)] });
9092
9263
  }
9093
9264
  //#endregion
9094
9265
  //#region src/updates-screen.tsx
@@ -9665,6 +9836,10 @@ function Admin({ config, store, host = config.backend, preview, siteUrl = "/", s
9665
9836
  ...strings,
9666
9837
  "publish.done": strings?.["demo.published"] ?? defaultStrings["demo.published"]
9667
9838
  } : strings, [demo, strings]);
9839
+ if (typeof location !== "undefined" && isPreviewAddress(location.hash)) return /* @__PURE__ */ jsx(SitePreview, {
9840
+ config,
9841
+ preview
9842
+ });
9668
9843
  return /* @__PURE__ */ jsx(StringsProvider, {
9669
9844
  strings: text,
9670
9845
  children: demo ? /* @__PURE__ */ jsx(AdminProvider, {
@@ -9689,7 +9864,7 @@ function Admin({ config, store, host = config.backend, preview, siteUrl = "/", s
9689
9864
  account: {
9690
9865
  user: backend.user,
9691
9866
  hostName: host.name,
9692
- canRedirect: host.canRedirect,
9867
+ redirectAddressesListed: host.canRedirect && host.redirectAddressesListed === true,
9693
9868
  signOut: () => {
9694
9869
  forgetAiKeys();
9695
9870
  signOut();