@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.d.ts +36 -0
- package/dist/index.js +1029 -854
- package/package.json +5 -5
- package/styles.css +12 -0
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
454
|
-
*
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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/
|
|
2931
|
-
|
|
2932
|
-
const
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
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
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
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
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
const
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
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
|
-
|
|
3216
|
-
|
|
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
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
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 === "
|
|
3282
|
-
className: "gfa-notice
|
|
3179
|
+
status.type === "photos" && /* @__PURE__ */ jsx("p", {
|
|
3180
|
+
className: "gfa-notice",
|
|
3283
3181
|
role: "status",
|
|
3284
|
-
children: t("ai.
|
|
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
|
|
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
|
|
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
|
|
5498
|
-
*
|
|
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
|
|
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
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
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
|
-
|
|
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(
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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/
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
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
|
|
8957
|
-
|
|
8958
|
-
|
|
8959
|
-
|
|
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
|
|
9054
|
-
*
|
|
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
|
|
9057
|
-
const
|
|
9058
|
-
const [
|
|
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
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
|
|
9065
|
-
|
|
9066
|
-
|
|
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
|
-
|
|
9225
|
+
delete window.goodfellowPreview;
|
|
9070
9226
|
};
|
|
9071
|
-
}, [
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
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
|
-
|
|
9867
|
+
redirectAddressesListed: host.canRedirect && host.redirectAddressesListed === true,
|
|
9693
9868
|
signOut: () => {
|
|
9694
9869
|
forgetAiKeys();
|
|
9695
9870
|
signOut();
|