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