@goodfellow-cms/admin 0.4.0 → 0.5.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/README.md +1 -1
- package/dist/editor.d.ts +18 -0
- package/dist/editor.js +1780 -0
- package/dist/entry-values-ocbalc9U.js +3952 -0
- package/dist/index.d.ts +448 -23
- package/dist/index.js +6229 -6109
- package/package.json +12 -7
- package/styles.css +510 -7
|
@@ -0,0 +1,3952 @@
|
|
|
1
|
+
import { COLOR_SCHEME_ATTRIBUTE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, MAX_UPLOAD_BYTES, MEDIA_DIR, MENUS_FILE, REDIRECTS_FILE, SHADCN_NAMESPACE, SHADCN_REGISTRY, SITE_FILE, ScheduledBuildsError, SignInError, addressPatternFor, adminOrigin, allPages, checkEntrySlug, checkPageAddress, checkSite, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, customCssFileChange, deleteCollectionChanges, describedConfig, duplicatePageData, entryFileChange, entryFileChange as entryFileChange$1, entryLanguage, entryPathIn, entryTitle, escapeStyleText, extensionOf, fileName, goodfellowRegistryUrl, googleFontsUrl, isDemoStore, keptDrafts, layoutFileChange, loadSiteContent, markdownToHtml, mediaFileChange, mediaKind, mediaKind as mediaKind$1, memoryKeeper, menusFileChange, moveEntryChanges, movePageChanges, newCollectionSettings, newDraftName, newPageData, outsideHost, pageFileChange, pageTitle, publishProblems, redirectsFileChange, relinkSite, relinkedChanges, scheduleOf, scheduleProblem, scheduleState, siteLanguages, siteSettingsFileChange, slugify, storedCss, storedData, storedEntryFields, themeToCss, todayIn, trimChars, uniqueName, upcomingChanges, updateSettingsFileChange, uploadName, wallClock, withoutCredits, writeChanges } from "@goodfellow-cms/core";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import TurndownService from "turndown";
|
|
4
|
+
import { gfm } from "turndown-plugin-gfm";
|
|
5
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/markdown-text.ts
|
|
7
|
+
let service;
|
|
8
|
+
/** Converts the formatted editor's HTML to Markdown, in the style most Markdown is written in. */
|
|
9
|
+
function htmlToMarkdown(html) {
|
|
10
|
+
if (!service) {
|
|
11
|
+
service = new TurndownService({
|
|
12
|
+
headingStyle: "atx",
|
|
13
|
+
bulletListMarker: "-",
|
|
14
|
+
codeBlockStyle: "fenced",
|
|
15
|
+
emDelimiter: "*",
|
|
16
|
+
strongDelimiter: "**",
|
|
17
|
+
hr: "---"
|
|
18
|
+
});
|
|
19
|
+
service.use(gfm);
|
|
20
|
+
service.addRule("underline", {
|
|
21
|
+
filter: ["u"],
|
|
22
|
+
replacement: (content) => content
|
|
23
|
+
});
|
|
24
|
+
service.addRule("strikethrough", {
|
|
25
|
+
filter: ["del", "s"],
|
|
26
|
+
replacement: (content) => `~~${content}~~`
|
|
27
|
+
});
|
|
28
|
+
service.addRule("listItem", {
|
|
29
|
+
filter: "li",
|
|
30
|
+
replacement(content, node) {
|
|
31
|
+
const parent = node.parentNode;
|
|
32
|
+
let marker = "- ";
|
|
33
|
+
if (parent?.nodeName === "OL") marker = `${Number(parent.getAttribute("start") ?? "1") + Array.prototype.indexOf.call(parent.children, node)}. `;
|
|
34
|
+
const indent = " ".repeat(marker.length);
|
|
35
|
+
const text = content.replace(/^\n+/, "").replace(/\n+$/, "").replace(/\n/g, `\n${indent}`);
|
|
36
|
+
return `${marker}${text}${node.nextSibling ? "\n" : ""}`;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return service.turndown(html).replace(/[ \t]+$/gm, "").trim();
|
|
41
|
+
}
|
|
42
|
+
/** The HTML the formatted editor starts from for some Markdown. */
|
|
43
|
+
function formattedHtml(markdown) {
|
|
44
|
+
return markdownToHtml(markdown);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The Markdown to save after the formatted editor changed `html`. If the text
|
|
48
|
+
* is the same as `original`'s, only written differently (the formatted editor
|
|
49
|
+
* tidies what it loads), `original` is kept, so the file isn't rewritten.
|
|
50
|
+
*/
|
|
51
|
+
function editedMarkdown(original, html) {
|
|
52
|
+
const edited = htmlToMarkdown(html);
|
|
53
|
+
return edited === htmlToMarkdown(formattedHtml(original)) ? original : edited;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/changes.ts
|
|
57
|
+
/**
|
|
58
|
+
* The file changes the admin panel saves. Most are built by core's helpers,
|
|
59
|
+
* which the Claude connector shares; converting a collection's items between
|
|
60
|
+
* Markdown and formatted text needs the browser's HTML handling, so it's here.
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* A stored value as it fits its collection's field now, or `undefined` if it
|
|
64
|
+
* doesn't: the field must exist, and a choice must still be offered. Tags
|
|
65
|
+
* keep the ones that still are.
|
|
66
|
+
*/
|
|
67
|
+
function keptValue(fields, name, value) {
|
|
68
|
+
const field = fields.get(name);
|
|
69
|
+
if (!field) return void 0;
|
|
70
|
+
const offered = (choice) => field.options?.some((option) => option.value === choice) === true;
|
|
71
|
+
if (field.type === "select") return offered(value) ? value : void 0;
|
|
72
|
+
if (field.type === "tags" && Array.isArray(value)) {
|
|
73
|
+
const tags = value.filter(offered);
|
|
74
|
+
if (tags.length === value.length) return value;
|
|
75
|
+
return tags.length > 0 ? tags : void 0;
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
/** Where each item's page goes when its collection's address patterns change, in each of the site's languages. */
|
|
80
|
+
function movedEntryPages(site, collection, settings) {
|
|
81
|
+
if (!collection.settings.path || !settings.path) return [];
|
|
82
|
+
return collection.entries.flatMap((entry) => {
|
|
83
|
+
const to = entryPathIn(site, settings, entry.slug, entryLanguage(site, entry));
|
|
84
|
+
return entry.path && to && to !== entry.path ? [{
|
|
85
|
+
from: entry.path,
|
|
86
|
+
to
|
|
87
|
+
}] : [];
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Saves a collection's new settings. Values of fields that were removed, and
|
|
92
|
+
* choices that are no longer offered, are removed from every entry in the
|
|
93
|
+
* same save, so a field added later with the same name starts empty. Given
|
|
94
|
+
* the `site`, a new address pattern points every link to an item's page at
|
|
95
|
+
* its new address, in the same save.
|
|
96
|
+
*/
|
|
97
|
+
function collectionSettingsChanges(collection, settings, site) {
|
|
98
|
+
const relinked = site ? relinkSite({
|
|
99
|
+
...site.content,
|
|
100
|
+
collections: site.content.collections.map((candidate) => candidate.id === collection.id ? {
|
|
101
|
+
...collection,
|
|
102
|
+
settings: {
|
|
103
|
+
...collection.settings,
|
|
104
|
+
template: settings.template
|
|
105
|
+
}
|
|
106
|
+
} : candidate)
|
|
107
|
+
}, movedEntryPages(site.content.settings, collection, settings), site.blocks) : void 0;
|
|
108
|
+
const current = relinked?.content.collections.find((candidate) => candidate.id === collection.id) ?? collection;
|
|
109
|
+
const relinkedFiles = new Set(relinked?.files);
|
|
110
|
+
const fields = new Map(settings.fields.map((field) => [field.name, field]));
|
|
111
|
+
const changes = [collectionFileChange(collection.id, {
|
|
112
|
+
...settings,
|
|
113
|
+
template: current.settings.template
|
|
114
|
+
})];
|
|
115
|
+
const before = collection.settings.markdown?.body;
|
|
116
|
+
const after = settings.markdown?.body;
|
|
117
|
+
const reformat = before !== after;
|
|
118
|
+
for (const entry of current.entries) {
|
|
119
|
+
const values = Object.entries(entry.content.fields);
|
|
120
|
+
const remaining = values.flatMap(([name, value]) => {
|
|
121
|
+
const kept = keptValue(fields, name, value);
|
|
122
|
+
return kept === void 0 ? [] : [[name, kept]];
|
|
123
|
+
});
|
|
124
|
+
if (!(remaining.length !== values.length || remaining.some(([name, value]) => value !== entry.content.fields[name])) && !reformat && !relinkedFiles.has(entry.file)) continue;
|
|
125
|
+
const kept = Object.fromEntries(remaining);
|
|
126
|
+
if (reformat) {
|
|
127
|
+
if (before !== void 0 && typeof kept[before] === "string") kept[before] = formattedHtml(kept[before]);
|
|
128
|
+
if (after !== void 0 && typeof kept[after] === "string") kept[after] = htmlToMarkdown(kept[after]);
|
|
129
|
+
}
|
|
130
|
+
const change = entryFileChange({
|
|
131
|
+
id: collection.id,
|
|
132
|
+
settings
|
|
133
|
+
}, entry.slug, kept, entry.content);
|
|
134
|
+
if (change.path !== entry.file) changes.push({
|
|
135
|
+
path: entry.file,
|
|
136
|
+
delete: true
|
|
137
|
+
});
|
|
138
|
+
changes.push(change);
|
|
139
|
+
}
|
|
140
|
+
if (relinked) changes.push(...relinkedChanges(relinked, [collection.file, ...collection.entries.map((entry) => entry.file)]));
|
|
141
|
+
return changes;
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/media.ts
|
|
145
|
+
/** Files in `public/media/` are served from `/media/` on the site. */
|
|
146
|
+
const MEDIA_URL_PREFIX = "/media/";
|
|
147
|
+
/** `public/media/photo.jpg` → `/media/photo.jpg`. */
|
|
148
|
+
function mediaUrl(path) {
|
|
149
|
+
return `${MEDIA_URL_PREFIX}${path.slice(MEDIA_DIR.length + 1)}`;
|
|
150
|
+
}
|
|
151
|
+
/** `/media/photo.jpg` → `public/media/photo.jpg`, or `undefined` for addresses outside the media folder. */
|
|
152
|
+
function mediaPath(url) {
|
|
153
|
+
if (!url.startsWith("/media/")) return void 0;
|
|
154
|
+
const rest = url.slice(7).split(/[?#]/)[0] ?? "";
|
|
155
|
+
if (!rest || rest.split("/").some((part) => part === "" || part === "." || part === "..")) return void 0;
|
|
156
|
+
return `${MEDIA_DIR}/${decodeURIComponent(rest)}`;
|
|
157
|
+
}
|
|
158
|
+
/** Where a media file is used, by name, so editors know what deleting or replacing it affects. */
|
|
159
|
+
function mediaUsage(content, url, names) {
|
|
160
|
+
const uses = (value) => JSON.stringify(value).includes(url);
|
|
161
|
+
const used = [];
|
|
162
|
+
if (uses(content.settings)) used.push(names.settings);
|
|
163
|
+
if (uses(content.header)) used.push(names.header);
|
|
164
|
+
if (uses(content.footer)) used.push(names.footer);
|
|
165
|
+
for (const page of content.pages) if (uses(page.content)) used.push(pageTitle(page));
|
|
166
|
+
for (const collection of content.collections) {
|
|
167
|
+
if (uses(collection.settings)) used.push(collection.settings.name);
|
|
168
|
+
for (const entry of collection.entries) if (uses(entry.content)) used.push(entryTitle(entry));
|
|
169
|
+
}
|
|
170
|
+
if (content.customCss.includes(url)) used.push(names.css);
|
|
171
|
+
return used;
|
|
172
|
+
}
|
|
173
|
+
/** The addresses of the site's uploaded images, such as `/media/photo.jpg`. */
|
|
174
|
+
function siteImages(media) {
|
|
175
|
+
return media.filter((path) => mediaKind(path) === "image").map(mediaUrl);
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/site-code.ts
|
|
179
|
+
/**
|
|
180
|
+
* What the admin panel needs the site's own code for: the pages its blocks add
|
|
181
|
+
* (a calendar's months, a list's later pages), and pages rendered to check a
|
|
182
|
+
* publish. The admin panel's page never runs that code: it asks the frame that
|
|
183
|
+
* does (`frameSiteCode()` in `frame/host.tsx`), which answers with `localSiteCode()` (`frame/local-code.ts`).
|
|
184
|
+
*/
|
|
185
|
+
/** The page at an address, as `findSitePage()` gives it: a page a build writes, or "Page not found". */
|
|
186
|
+
async function findPage(code, content, today, path) {
|
|
187
|
+
const pages = await code.sitePages(content, today);
|
|
188
|
+
const page = pages.find((candidate) => candidate.path === path);
|
|
189
|
+
if (page) return {
|
|
190
|
+
page,
|
|
191
|
+
status: 200
|
|
192
|
+
};
|
|
193
|
+
const notFound = pages.find((candidate) => candidate.path === "/404");
|
|
194
|
+
return notFound && {
|
|
195
|
+
page: notFound,
|
|
196
|
+
status: 404
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The pages a publish changes, each with the file its own content comes from:
|
|
201
|
+
* a page or item, an item's page for its collection's design, and the home
|
|
202
|
+
* page for the header and footer.
|
|
203
|
+
*/
|
|
204
|
+
function pagesToRender(content, changed) {
|
|
205
|
+
const pages = allPages(content);
|
|
206
|
+
const targets = pages.filter((page) => changed.has(page.file)).map((page) => ({
|
|
207
|
+
page,
|
|
208
|
+
file: page.file
|
|
209
|
+
}));
|
|
210
|
+
for (const collection of content.collections) {
|
|
211
|
+
if (!changed.has(collection.file)) continue;
|
|
212
|
+
const first = pages.find((page) => page.entry?.collection === collection.id);
|
|
213
|
+
if (first && !targets.some((target) => target.page === first)) targets.push({
|
|
214
|
+
page: first,
|
|
215
|
+
file: collection.file
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if ((changed.has(HEADER_FILE) || changed.has(FOOTER_FILE)) && targets.length === 0) {
|
|
219
|
+
const home = pages.find((page) => page.path === "/") ?? pages[0];
|
|
220
|
+
if (home) targets.push({
|
|
221
|
+
page: home,
|
|
222
|
+
file: home.file
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return targets.slice(0, 10);
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/site-checks.ts
|
|
229
|
+
/** The media library's files as the addresses content uses, such as `/media/photo.jpg`. */
|
|
230
|
+
function mediaAddresses(paths) {
|
|
231
|
+
return [...paths].filter((path) => path.startsWith(`${MEDIA_DIR}/`)).map(mediaUrl);
|
|
232
|
+
}
|
|
233
|
+
/** The site as it would be once the changes are published, read like the site itself. */
|
|
234
|
+
function contentWithChanges(source, changes) {
|
|
235
|
+
const changed = new Map(changes.map((change) => [change.path, change]));
|
|
236
|
+
return loadSiteContent({
|
|
237
|
+
read: async (path) => {
|
|
238
|
+
const change = changed.get(path);
|
|
239
|
+
if (!change) return source.read(path);
|
|
240
|
+
return "content" in change ? change.content : void 0;
|
|
241
|
+
},
|
|
242
|
+
list: async (dir) => {
|
|
243
|
+
const listed = new Set(await source.list(dir));
|
|
244
|
+
for (const change of changes) {
|
|
245
|
+
if (!change.path.startsWith(`${dir}/`)) continue;
|
|
246
|
+
if ("delete" in change) listed.delete(change.path);
|
|
247
|
+
else listed.add(change.path);
|
|
248
|
+
}
|
|
249
|
+
return [...listed];
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The site checks for a publish: the site as it's loaded, and as the changes
|
|
255
|
+
* would leave it, with the pages they change rendered. A site the changes
|
|
256
|
+
* would leave unreadable isn't checked; loading it again after publishing
|
|
257
|
+
* says what's wrong.
|
|
258
|
+
*/
|
|
259
|
+
async function checkPublish(source, loaded, changes, site) {
|
|
260
|
+
const { blocks, code } = site;
|
|
261
|
+
let after;
|
|
262
|
+
try {
|
|
263
|
+
after = await contentWithChanges(source, changes);
|
|
264
|
+
} catch {
|
|
265
|
+
return {
|
|
266
|
+
blocking: [],
|
|
267
|
+
warnings: [],
|
|
268
|
+
content: loaded.content
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const media = new Set(loaded.media);
|
|
272
|
+
for (const change of changes) if ("delete" in change) media.delete(change.path);
|
|
273
|
+
else if ("bytes" in change) media.add(change.path);
|
|
274
|
+
const today = todayIn(after.settings.timeZone);
|
|
275
|
+
const changed = new Set(changes.map((change) => change.path));
|
|
276
|
+
const [pagesBefore, pagesAfter, rendered] = await Promise.all([
|
|
277
|
+
code.sitePages(loaded.content, today).catch(() => allPages(loaded.content)),
|
|
278
|
+
code.sitePages(after, today).catch(() => allPages(after)),
|
|
279
|
+
code.renderedProblems(after, pagesToRender(after, changed), today).catch(() => [])
|
|
280
|
+
]);
|
|
281
|
+
const before = checkSite(loaded.content, {
|
|
282
|
+
media: mediaAddresses(loaded.media),
|
|
283
|
+
blocks,
|
|
284
|
+
pages: pagesBefore
|
|
285
|
+
});
|
|
286
|
+
const problems = [...checkSite(after, {
|
|
287
|
+
media: mediaAddresses(media),
|
|
288
|
+
blocks,
|
|
289
|
+
pages: pagesAfter
|
|
290
|
+
}), ...rendered];
|
|
291
|
+
return {
|
|
292
|
+
...publishProblems(before, problems, changed),
|
|
293
|
+
content: after
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/** Where a problem is, as the admin panel names it, such as `The page "About us"`. */
|
|
297
|
+
function problemPlace(t, content, file) {
|
|
298
|
+
if (file === HEADER_FILE) return t("checks.place.header");
|
|
299
|
+
if (file === FOOTER_FILE) return t("checks.place.footer");
|
|
300
|
+
if (file === MENUS_FILE) return t("checks.place.menus");
|
|
301
|
+
if (file === SITE_FILE) return t("checks.place.settings");
|
|
302
|
+
if (file === REDIRECTS_FILE) return t("checks.place.redirects");
|
|
303
|
+
const page = content.pages.find((candidate) => candidate.file === file);
|
|
304
|
+
if (page) return t("checks.place.page", { title: pageTitle(page) });
|
|
305
|
+
for (const collection of content.collections) {
|
|
306
|
+
if (collection.file === file) return t("checks.place.design", { collection: collection.settings.name });
|
|
307
|
+
const entry = collection.entries.find((candidate) => candidate.file === file);
|
|
308
|
+
if (entry) return t("checks.place.entry", {
|
|
309
|
+
title: entryTitle(entry),
|
|
310
|
+
collection: collection.settings.name
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return t("checks.place.file", { file });
|
|
314
|
+
}
|
|
315
|
+
/** What a theme color problem means, which needs no content to say. */
|
|
316
|
+
function describeContrast(t, problem) {
|
|
317
|
+
return t(problem.dark ? "checks.low-contrast.dark" : "checks.low-contrast", {
|
|
318
|
+
text: t(`theme.color.${problem.text}`),
|
|
319
|
+
background: t(`theme.color.${problem.background}`),
|
|
320
|
+
ratio: problem.ratio.toFixed(1)
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/** One of the site's languages, as it's named in the switch. */
|
|
324
|
+
function languageName$1(content, code) {
|
|
325
|
+
return siteLanguages(content.settings).find((language) => language.code === code)?.name ?? code;
|
|
326
|
+
}
|
|
327
|
+
/** What a problem means, in plain words. */
|
|
328
|
+
function describeProblem(t, content, problem) {
|
|
329
|
+
switch (problem.kind) {
|
|
330
|
+
case "missing-page":
|
|
331
|
+
case "missing-media": return t(`checks.${problem.kind}`, {
|
|
332
|
+
place: problemPlace(t, content, problem.file),
|
|
333
|
+
target: problem.target
|
|
334
|
+
});
|
|
335
|
+
case "no-title":
|
|
336
|
+
case "no-description": return t(`checks.${problem.kind}`, { place: problemPlace(t, content, problem.file) });
|
|
337
|
+
case "bad-translation": return t("checks.bad-translation", {
|
|
338
|
+
place: problemPlace(t, content, problem.file),
|
|
339
|
+
target: problem.target
|
|
340
|
+
});
|
|
341
|
+
case "missing-translation":
|
|
342
|
+
case "outdated-translation": return t(`checks.${problem.kind}`, {
|
|
343
|
+
place: problemPlace(t, content, problem.file),
|
|
344
|
+
language: languageName$1(content, problem.language)
|
|
345
|
+
});
|
|
346
|
+
case "low-contrast": return describeContrast(t, problem);
|
|
347
|
+
case "no-alt": return t("checks.no-alt", {
|
|
348
|
+
place: problemPlace(t, content, problem.file),
|
|
349
|
+
image: problem.target.slice(problem.target.lastIndexOf("/") + 1) || problem.target
|
|
350
|
+
});
|
|
351
|
+
case "skipped-heading": return problem.previous === void 0 ? t("checks.skipped-heading.first", {
|
|
352
|
+
place: problemPlace(t, content, problem.file),
|
|
353
|
+
text: problem.text,
|
|
354
|
+
level: String(problem.level)
|
|
355
|
+
}) : t("checks.skipped-heading", {
|
|
356
|
+
place: problemPlace(t, content, problem.file),
|
|
357
|
+
text: problem.text,
|
|
358
|
+
level: String(problem.level),
|
|
359
|
+
previous: String(problem.previous)
|
|
360
|
+
});
|
|
361
|
+
case "vague-link": return t("checks.vague-link", {
|
|
362
|
+
place: problemPlace(t, content, problem.file),
|
|
363
|
+
text: problem.text
|
|
364
|
+
});
|
|
365
|
+
case "empty-link": return t("checks.empty-link", {
|
|
366
|
+
place: problemPlace(t, content, problem.file),
|
|
367
|
+
target: problem.target
|
|
368
|
+
});
|
|
369
|
+
case "accessibility": return t("checks.accessibility", {
|
|
370
|
+
place: problemPlace(t, content, problem.file),
|
|
371
|
+
help: problem.help
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/strings.tsx
|
|
377
|
+
/**
|
|
378
|
+
* Every piece of text the admin panel shows, so it can be reworded or
|
|
379
|
+
* translated. `{name}` placeholders are filled in when the text is used.
|
|
380
|
+
* Puck's own editor text is translated separately, with its `dictionary`.
|
|
381
|
+
*/
|
|
382
|
+
const defaultStrings = {
|
|
383
|
+
"app.title": "Site admin",
|
|
384
|
+
"app.viewSite": "View site",
|
|
385
|
+
"nav.pages": "Pages",
|
|
386
|
+
"nav.collections": "Collections",
|
|
387
|
+
"nav.media": "Media",
|
|
388
|
+
"nav.blocks": "Blocks",
|
|
389
|
+
"nav.layout": "Header & Footer",
|
|
390
|
+
"nav.settings": "Site Settings",
|
|
391
|
+
loading: "Loading…",
|
|
392
|
+
"setup.noBackend": "This site's settings don't say where the site is stored, so the admin panel can't open it. A developer needs to add a backend to goodfellow.config.tsx.",
|
|
393
|
+
"signIn.title": "Sign in to edit this site",
|
|
394
|
+
"signIn.intro": "The site is stored on {host}. Sign in with a {host} account that's allowed to change it.",
|
|
395
|
+
"signIn.redirect": "Sign in with {host}",
|
|
396
|
+
"signIn.orToken": "Or sign in with an access token",
|
|
397
|
+
"signIn.token.title": "Sign in with an access token",
|
|
398
|
+
"signIn.token.step1": "Create an access token on {host}. This link opens {host} with the right settings already chosen:",
|
|
399
|
+
"signIn.token.create": "Create a token on {host}",
|
|
400
|
+
"signIn.token.createBroad": "Create a broader token instead",
|
|
401
|
+
"signIn.token.createBroadHint": "Use this if the site belongs to someone else and the first link doesn't let you choose it. This kind of token can change everything your account can.",
|
|
402
|
+
"signIn.token.chooseSite": "If {host} asks what the token can access, choose this site.",
|
|
403
|
+
"signIn.token.step2": "Copy the token and paste it here:",
|
|
404
|
+
"signIn.token.label": "Access token",
|
|
405
|
+
"signIn.remember": "Stay signed in on this device",
|
|
406
|
+
"signIn.rememberHint": "Leave this off on shared or public computers.",
|
|
407
|
+
"signIn.submit": "Sign in",
|
|
408
|
+
"signIn.checking": "Signing in…",
|
|
409
|
+
"signIn.error.invalid": "That sign-in didn't work. Check that the whole token was copied and that it hasn't expired.",
|
|
410
|
+
"signIn.error.no-access": "That account can't see this site. Make sure the token was given access to it.",
|
|
411
|
+
"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.",
|
|
412
|
+
"signIn.error.redirect-failed": "Signing in didn't finish. Try again.",
|
|
413
|
+
"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.",
|
|
414
|
+
"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.",
|
|
415
|
+
"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.",
|
|
416
|
+
"signIn.error.local-address": "Signing in with {host} works only on the published site. Sign in with an access token here.",
|
|
417
|
+
"signIn.error.not-owner": "That account isn't an owner of this site on {host}.",
|
|
418
|
+
"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.",
|
|
419
|
+
"signIn.install": "Install Goodfellow on {host}",
|
|
420
|
+
"signIn.error.expired": "You were signed out because your sign-in expired. Sign in again to keep editing.",
|
|
421
|
+
"signIn.error.other": "Couldn't reach {host}. Check your connection and try again.",
|
|
422
|
+
"signIn.app.intro": "Sign in with your account on this site to edit it.",
|
|
423
|
+
"signIn.app.button": "Sign in",
|
|
424
|
+
"signIn.app.expired": "You've been signed out. Sign in again to keep editing.",
|
|
425
|
+
"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.",
|
|
426
|
+
"signIn.app.error": "Couldn't reach the site. Check your connection and try again.",
|
|
427
|
+
"owners.only": "Only the site's owners can change this.",
|
|
428
|
+
"owners.onlyHint": "Ask one of them if something here needs changing.",
|
|
429
|
+
"account.signedInAs": "Signed in as {name}",
|
|
430
|
+
"account.signOut": "Sign out",
|
|
431
|
+
"deploy.building": "Updating the live site…",
|
|
432
|
+
"deploy.live": "The live site is up to date",
|
|
433
|
+
"deploy.failed": "The live site couldn't be updated",
|
|
434
|
+
"build.why": "What went wrong?",
|
|
435
|
+
"build.title": "Why the live site wasn't updated",
|
|
436
|
+
"build.saved": "Your changes are published and saved. Visitors see the site as it was until it's rebuilt, which every publish tries again.",
|
|
437
|
+
"build.notStarted": "{host} didn't start rebuilding the site. That usually means the account the site belongs to has used up its free build minutes for the month, or its billing on {host} needs attention. One of the site's owners can check the account's billing settings on {host}.",
|
|
438
|
+
"build.install": "The site's packages couldn't be installed, so it couldn't be rebuilt. That's sometimes a passing problem with the service the packages come from, so publishing again later may work. If it keeps happening, a developer needs to look at the details.",
|
|
439
|
+
"build.pages": "{host} Pages isn't set up for the site, so there's nowhere to put it online. One of the site's owners needs to turn it on in the site's settings on {host}, under Pages.",
|
|
440
|
+
"build.deploy": "The site was rebuilt, but {host} couldn't put it online. That's usually a passing problem, so the next publish may work. If it keeps happening, check the site's Pages settings on {host}.",
|
|
441
|
+
"build.host": "A problem on {host}'s side stopped the rebuild. That's usually a passing problem, so the next publish may work.",
|
|
442
|
+
"build.content": "The site couldn't be rebuilt, because {file} has a problem. Fix it in the admin panel, or put back a version that worked from its version history.",
|
|
443
|
+
"build.contentHistory": "Version history of {file}",
|
|
444
|
+
"build.codeChanged": "The site couldn't be rebuilt after its blocks changed. Undo the change under Blocks, by removing what was just added or adding back what was removed, and ask a developer to look at the details.",
|
|
445
|
+
"build.blocks": "Go to Blocks",
|
|
446
|
+
"build.codeFile": "The site's code couldn't be rebuilt: there's a problem in {file}. A developer needs to look at the details.",
|
|
447
|
+
"build.code": "The site couldn't be rebuilt. A developer needs to look at the details.",
|
|
448
|
+
"build.unknown": "{host} didn't say why the rebuild failed. Its details may help a developer.",
|
|
449
|
+
"build.hostDetails": "See the details on {host}",
|
|
450
|
+
"loadError.title": "The admin panel couldn't open this site",
|
|
451
|
+
"outdatedAdminPage.title": "This site's admin page needs updating",
|
|
452
|
+
"outdatedAdminPage.message": "This site's admin page is from before the editor kept blocks away from your sign-in, so the admin panel doesn't open on it. Goodfellow's next update changes it, unless someone has changed the page. Whoever looks after the site's code can also change it now: the details say how.",
|
|
453
|
+
"outdatedAdminPage.details": "Run npx goodfellow-next update, or replace these files with the Next.js starter's, as they are below. The site's blocks then run in the editor's sandboxed frames, at /admin/editor/.",
|
|
454
|
+
"loadError.contentProblems": "Some of the site's files have problems, so they can't be edited until they're fixed.",
|
|
455
|
+
"loadError.other": "Something went wrong while loading the site. Check your connection and try again.",
|
|
456
|
+
"action.tryAgain": "Try again",
|
|
457
|
+
"action.cancel": "Cancel",
|
|
458
|
+
"action.close": "Close",
|
|
459
|
+
"action.reload": "Reload",
|
|
460
|
+
"action.undo": "Undo",
|
|
461
|
+
"action.redo": "Redo",
|
|
462
|
+
"action.undoHint": "Undo the last change (Ctrl+Z, or ⌘Z on a Mac)",
|
|
463
|
+
"action.redoHint": "Redo what you undid (Ctrl+Shift+Z, or ⇧⌘Z on a Mac)",
|
|
464
|
+
"details.show": "Details",
|
|
465
|
+
"publish.button": "Publish",
|
|
466
|
+
"editor.leftSidebar": "Show or hide blocks and outline",
|
|
467
|
+
"editor.rightSidebar": "Show or hide settings",
|
|
468
|
+
"editor.viewport": "Preview width",
|
|
469
|
+
"editor.viewport.full": "Full width",
|
|
470
|
+
"editor.viewport.tablet": "Tablet",
|
|
471
|
+
"editor.viewport.phone": "Phone",
|
|
472
|
+
"editor.failed": "The editor stopped working. Reload the page to start it again.",
|
|
473
|
+
"editor.frame": "Editing {title}",
|
|
474
|
+
"publish.publishing": "Publishing…",
|
|
475
|
+
"publish.done": "Published.",
|
|
476
|
+
"publish.conflict": "Someone else changed the site since you opened this page, so your changes weren't published. Reload to see their changes, then make yours again.",
|
|
477
|
+
"publish.error": "Your changes couldn't be published. Check your connection and try again.",
|
|
478
|
+
"publish.invalid": "Fix the highlighted problems before publishing.",
|
|
479
|
+
"draft.label": "Draft",
|
|
480
|
+
"draft.add": "Add to draft",
|
|
481
|
+
"draft.addHint": "Keep this change with others to publish together later, instead of publishing it now",
|
|
482
|
+
"draft.adding": "Adding to the draft…",
|
|
483
|
+
"draft.added": "Added to the draft.",
|
|
484
|
+
"draft.banner": "You're working in a draft started {date}. What you change goes into it, and visitors see it when it's published.",
|
|
485
|
+
"draft.bannerKept": "You're working in a draft started {date}, kept in this browser. What you change goes into it, and visitors see it when it's published.",
|
|
486
|
+
"draft.changes": "{count} files changed",
|
|
487
|
+
"draft.oneChange": "1 file changed",
|
|
488
|
+
"draft.publishAll": "Publish all",
|
|
489
|
+
"draft.publishing": "Publishing the draft…",
|
|
490
|
+
"draft.published": "The draft is published.",
|
|
491
|
+
"draft.submit": "Submit for review",
|
|
492
|
+
"draft.submitted": "Submitted for review.",
|
|
493
|
+
"draft.seeReview": "See it",
|
|
494
|
+
"draft.discard": "Throw away",
|
|
495
|
+
"draft.discardTitle": "Throw away this draft?",
|
|
496
|
+
"draft.discardBody": "Everything in it will be lost. The live site stays as it is.",
|
|
497
|
+
"draft.leave": "Back to the live site",
|
|
498
|
+
"draft.waiting": "A draft started {date} is waiting to be published.",
|
|
499
|
+
"draft.waitingMany": "{count} drafts are waiting to be published.",
|
|
500
|
+
"draft.choose": "Draft to open",
|
|
501
|
+
"draft.started": "Started {date}",
|
|
502
|
+
"draft.open": "Open",
|
|
503
|
+
"draft.gone": "That draft has been published or thrown away.",
|
|
504
|
+
"draft.conflict": "Someone else has published changes to some of the same pages or settings since this draft started, so it can't be published as it is. Submit it for review, or throw it away and make the changes again.",
|
|
505
|
+
"draft.conflictKept": "Someone else has published changes to some of the same pages or settings since this draft started, so it can't be published as it is. Throw it away and make the changes again.",
|
|
506
|
+
"draft.error": "The draft couldn't be published. Check your connection and try again.",
|
|
507
|
+
"draft.submitError": "The draft couldn't be submitted for review. Check your connection and try again.",
|
|
508
|
+
"draft.discardError": "The draft couldn't be thrown away. Check your connection and try again.",
|
|
509
|
+
"draft.message": "Publish the draft {name}",
|
|
510
|
+
"draft.reviewTitle": "Draft {name}, from the admin panel",
|
|
511
|
+
"draft.reviewDescription": "Changes made in the admin panel:",
|
|
512
|
+
"checks.blocked.title": "This can't be published yet",
|
|
513
|
+
"checks.blocked.intro": "Publishing this would leave links that go nowhere. Change or remove them first.",
|
|
514
|
+
"checks.warnings.title": "Publish anyway?",
|
|
515
|
+
"checks.warnings.intro": "A few things could be better. You can publish anyway, or go back and fix them first.",
|
|
516
|
+
"checks.alsoWarnings": "These could be better too:",
|
|
517
|
+
"checks.publishAnyway": "Publish anyway",
|
|
518
|
+
"checks.goBack": "Go back",
|
|
519
|
+
"checks.missing-page": "{place} links to {target}, which isn't a page on the site.",
|
|
520
|
+
"checks.missing-media": "{place} uses {target}, which isn't in the media library.",
|
|
521
|
+
"checks.no-title": "{place} has no title, so browser tabs and search results show only the site's name.",
|
|
522
|
+
"checks.missing-translation": "{place} has no translation in {language} yet. Visitors reading in {language} are sent to its home page instead.",
|
|
523
|
+
"checks.outdated-translation": "{place} has changed since its translation in {language} was made. Update the translation, then mark it as up to date.",
|
|
524
|
+
"checks.bad-translation": "{place} says it's a translation of {target}, but only pages and items in the site's main language have translations.",
|
|
525
|
+
"checks.no-description": "{place} has no description for search results and link previews to show.",
|
|
526
|
+
"checks.low-contrast": "Text in \"{text}\" is hard to read on \"{background}\": its contrast is {ratio} to 1, and text needs at least 4.5 to 1.",
|
|
527
|
+
"checks.low-contrast.dark": "In dark mode, text in \"{text}\" is hard to read on \"{background}\": its contrast is {ratio} to 1, and text needs at least 4.5 to 1.",
|
|
528
|
+
"checks.no-alt": "{place} has an image, {image}, with no description for people who can't see it. Describe it in the image's settings, or say it's only decoration.",
|
|
529
|
+
"checks.skipped-heading": "{place} has the heading \"{text}\" at level {level}, after one at level {previous}. Headings shouldn't skip a level, or people who move through a page by its headings think they've missed something.",
|
|
530
|
+
"checks.skipped-heading.first": "{place} starts with the heading \"{text}\", at level {level}. Start with the page's title (level 1) or a section (level 2).",
|
|
531
|
+
"checks.vague-link": "{place} has a link, \"{text}\", that doesn't say where it goes. Say what people will find there, such as \"Our Mass times\" rather than \"Click here\".",
|
|
532
|
+
"checks.empty-link": "{place} has a link to {target} with no text, so people who can't see the page don't know where it goes.",
|
|
533
|
+
"checks.accessibility": "{place}: {help}",
|
|
534
|
+
"checks.place.page": "The page \"{title}\"",
|
|
535
|
+
"checks.place.entry": "\"{title}\" in {collection}",
|
|
536
|
+
"checks.place.design": "The design of {collection}",
|
|
537
|
+
"checks.place.header": "The header",
|
|
538
|
+
"checks.place.footer": "The footer",
|
|
539
|
+
"checks.place.menus": "A menu",
|
|
540
|
+
"checks.place.settings": "Site Settings",
|
|
541
|
+
"checks.place.redirects": "A redirect",
|
|
542
|
+
"checks.place.file": "{file}",
|
|
543
|
+
"unsaved.confirm": "You have changes that aren't published yet. Leave anyway and lose them?",
|
|
544
|
+
"unsaved.label": "Unpublished changes",
|
|
545
|
+
"pages.title": "Pages",
|
|
546
|
+
"pages.new": "New page",
|
|
547
|
+
"pages.empty": "This site has no pages yet.",
|
|
548
|
+
"pages.column.title": "Title",
|
|
549
|
+
"pages.column.address": "Address",
|
|
550
|
+
"pages.edit": "Edit",
|
|
551
|
+
"pages.move": "Change address",
|
|
552
|
+
"pages.duplicate": "Duplicate",
|
|
553
|
+
"pages.delete": "Delete",
|
|
554
|
+
"pages.view": "View",
|
|
555
|
+
"pages.homeCantDelete": "The home page can't be deleted.",
|
|
556
|
+
"pages.schedule": "Schedule",
|
|
557
|
+
"sharePreview.title": "When a link to this page is shared",
|
|
558
|
+
"sharePreview.noImage": "No image",
|
|
559
|
+
"sharePreview.noAddress": "Link previews show the image once the site's address is set in Site Settings, since they need its full address.",
|
|
560
|
+
"sharePreview.hidden": "This page is hidden from search engines, so it isn't in search results or the sitemap.",
|
|
561
|
+
"schedulePage.title": "When “{title}” is on the site",
|
|
562
|
+
"schedulePage.submit": "Publish",
|
|
563
|
+
"schedulePage.message": "Schedule page \"{title}\"",
|
|
564
|
+
"schedule.title": "When it's on the site",
|
|
565
|
+
"schedule.showFrom": "Show from",
|
|
566
|
+
"schedule.showFromHint": "Leave empty to show it as soon as it's published.",
|
|
567
|
+
"schedule.hideAfter": "Hide after",
|
|
568
|
+
"schedule.hideAfterHint": "Leave empty to keep it on the site.",
|
|
569
|
+
"schedule.zone": "Times are in the site's time zone, {zone}.",
|
|
570
|
+
"schedule.zoneNew": "Times are in your computer's time zone, {zone}, which becomes the site's time zone.",
|
|
571
|
+
"schedule.zoneNone": "Times are in UTC, since the site has no time zone. Choose one in Site Settings.",
|
|
572
|
+
"schedule.invalid": "Enter a date and a time.",
|
|
573
|
+
"schedule.order": "Choose a time after the one it's shown from.",
|
|
574
|
+
"schedule.timing.exact": "The site is rebuilt at each of these times, so changes go live then.",
|
|
575
|
+
"schedule.timing.hourly": "The site checks each hour for changes that are due, so changes go live within an hour of their time.",
|
|
576
|
+
"schedule.timing.nightly": "The site is rebuilt each night, so changes go live with the first rebuild after their time.",
|
|
577
|
+
"schedule.timing.none": "Changes go live the next time the site is rebuilt after their time, such as when someone publishes.",
|
|
578
|
+
"schedule.timing.notAllowed": "{host} didn't let you have the site rebuilt at these times, so changes go live with the nightly rebuild after their time, or at their time once an owner opens the admin panel.",
|
|
579
|
+
"schedule.public": "Anyone can read what's scheduled before it's shown, since the site's files on {host} are public. Don't schedule anything that must stay secret until then.",
|
|
580
|
+
"schedule.internal": "Anyone signed in to {host} can read what's scheduled before it's shown, since the site's files are visible to all of them. Don't schedule anything that must stay secret until then.",
|
|
581
|
+
"schedule.status.waiting": "Shown from {time}",
|
|
582
|
+
"schedule.status.hides": "Hidden after {time}",
|
|
583
|
+
"schedule.status.hidden": "Hidden since {time}",
|
|
584
|
+
"comingUp.title": "Coming up",
|
|
585
|
+
"comingUp.show": "Shown",
|
|
586
|
+
"comingUp.hide": "Hidden",
|
|
587
|
+
"comingUp.zone": "Times are in the site's time zone, {zone}.",
|
|
588
|
+
"newPage.title": "New page",
|
|
589
|
+
"newPage.pageTitle": "Page title",
|
|
590
|
+
"newPage.address": "Address",
|
|
591
|
+
"newPage.addressHint": "The part of the web address after the site's name, such as /about or /events/picnic.",
|
|
592
|
+
"newPage.create": "Create page",
|
|
593
|
+
"newPage.message": "Add page \"{title}\"",
|
|
594
|
+
"duplicatePage.title": "Duplicate \"{title}\"",
|
|
595
|
+
"duplicatePage.pageTitle": "{title} (copy)",
|
|
596
|
+
"duplicatePage.create": "Duplicate page",
|
|
597
|
+
"duplicatePage.message": "Add page \"{title}\", a copy of \"{from}\"",
|
|
598
|
+
"movePage.title": "Change the address of {title}",
|
|
599
|
+
"movePage.address": "New address",
|
|
600
|
+
"movePage.links": "Links to it anywhere on this site, menus included, change to the new address.",
|
|
601
|
+
"movePage.redirect": "Keep {from} working by sending visitors on to the page",
|
|
602
|
+
"movePage.redirectHint": "Links to the old address elsewhere, such as on other websites, keep working. Site Settings → Redirects lists it.",
|
|
603
|
+
"movePage.warning": "Links to the old address on other websites will stop working.",
|
|
604
|
+
"movePage.submit": "Change address",
|
|
605
|
+
"movePage.message": "Move page \"{title}\" from {from} to {to}",
|
|
606
|
+
"deletePage.title": "Delete {title}?",
|
|
607
|
+
"deletePage.body": "The page at {path} will be removed from the site. If anything on the site links to it, those links need changing first.",
|
|
608
|
+
"deletePage.submit": "Delete page",
|
|
609
|
+
"deletePage.message": "Delete page \"{title}\"",
|
|
610
|
+
"address.invalid": "Use lowercase letters, numbers and hyphens, with / between parts, such as /about-us.",
|
|
611
|
+
"address.reserved": "This address is used by the admin panel. Choose another.",
|
|
612
|
+
"address.taken": "Another page already uses this address.",
|
|
613
|
+
"field.required": "This can't be empty.",
|
|
614
|
+
"editPage.message": "Update page \"{title}\"",
|
|
615
|
+
"editPage.notFound": "This page doesn't exist. It may have been moved or deleted.",
|
|
616
|
+
"editPage.backToPages": "Back to pages",
|
|
617
|
+
"versions.button": "Version history",
|
|
618
|
+
"versions.title": "Version history: {title}",
|
|
619
|
+
"versions.back": "Back to editing",
|
|
620
|
+
"versions.intro": "Every version that's been published, newest first. Choose one to see it, then restore it to put it back. Something that was moved to a new address starts a new history there.",
|
|
621
|
+
"versions.notFound": "There's no history to show here. It may have been moved or deleted.",
|
|
622
|
+
"versions.local": "Version history is in the published site's admin panel, where you sign in with GitHub or GitLab.",
|
|
623
|
+
"versions.demo": "Version history isn't kept in a demo.",
|
|
624
|
+
"versions.loading": "Loading versions…",
|
|
625
|
+
"versions.loadingVersion": "Loading this version…",
|
|
626
|
+
"versions.error": "The versions couldn't be loaded. Check your connection and try again.",
|
|
627
|
+
"versions.empty": "Nothing has been published here yet.",
|
|
628
|
+
"versions.current": "Current version",
|
|
629
|
+
"versions.by": "{date}, by {author}",
|
|
630
|
+
"versions.more": "Show older versions",
|
|
631
|
+
"versions.preview": "The version from {date}",
|
|
632
|
+
"versions.noPreview": "This item has no page of its own, so there's nothing to preview.",
|
|
633
|
+
"versions.restore": "Restore this version",
|
|
634
|
+
"versions.restoreTitle": "Restore the version from {date}?",
|
|
635
|
+
"versions.restoreBody": "{title} goes back to how it was then, and that's published straight away. The current version stays in the history, so you can restore it later.",
|
|
636
|
+
"versions.restoring": "Restoring…",
|
|
637
|
+
"versions.restoreMessage": "Restore \"{title}\" to the version from {date}",
|
|
638
|
+
"versions.restored": "Restored the version from {date}.",
|
|
639
|
+
"versions.readError": "This version couldn't be loaded. Check your connection and try again.",
|
|
640
|
+
"versions.unfit": "This version can't be restored, because it doesn't fit the site as it is now, for example because the choices for one of its fields have changed.",
|
|
641
|
+
"versions.unknownBlocks": "This version can't be restored, because it uses blocks the site doesn't have any more: {blocks}.",
|
|
642
|
+
"layout.header": "Header",
|
|
643
|
+
"layout.footer": "Footer",
|
|
644
|
+
"layout.headerWord": "header",
|
|
645
|
+
"layout.footerWord": "footer",
|
|
646
|
+
"layout.headerMessage": "Update the header",
|
|
647
|
+
"layout.footerMessage": "Update the footer",
|
|
648
|
+
"collections.title": "Collections",
|
|
649
|
+
"collections.intro": "Collections are groups of similar items, such as videos, events or staff. Each collection has its own fields, and can give every item a page that shares one design.",
|
|
650
|
+
"collections.new": "New collection",
|
|
651
|
+
"collections.empty": "There are no collections yet.",
|
|
652
|
+
"collections.column.name": "Name",
|
|
653
|
+
"collections.column.count": "Items",
|
|
654
|
+
"collections.column.address": "Pages",
|
|
655
|
+
"collections.noPages": "No pages of their own",
|
|
656
|
+
"newCollection.title": "New collection",
|
|
657
|
+
"newCollection.kind": "Start with",
|
|
658
|
+
"newCollection.kindBlank": "A title and text",
|
|
659
|
+
"newCollection.kindEvents": "Events",
|
|
660
|
+
"newCollection.kindEventsHint": "Each item has a date and time, which can repeat, a place and a summary. Calendar blocks show them, and visitors can add them to their own calendars.",
|
|
661
|
+
"newCollection.eventsName": "Events",
|
|
662
|
+
"newCollection.eventsEntryName": "Event",
|
|
663
|
+
"newCollection.timeZone": "The site's time zone will be set to {zone}. You can change it in Site Settings.",
|
|
664
|
+
"newCollection.name": "Name",
|
|
665
|
+
"newCollection.nameHint": "What the whole collection is called, such as Videos or Events.",
|
|
666
|
+
"newCollection.nameTaken": "Another collection already has a name like this. Choose another.",
|
|
667
|
+
"newCollection.entryName": "One item is called",
|
|
668
|
+
"newCollection.entryNameHint": "Such as Video or Event.",
|
|
669
|
+
"newCollection.pages": "Give each item its own page",
|
|
670
|
+
"newCollection.address": "Address of the pages",
|
|
671
|
+
"newCollection.addressHint": "Each item's page goes inside this address, such as /videos/easter-vigil.",
|
|
672
|
+
"newCollection.create": "Create collection",
|
|
673
|
+
"newCollection.message": "Add collection \"{name}\"",
|
|
674
|
+
"collection.tab.entries": "Items",
|
|
675
|
+
"collection.tab.template": "Page design",
|
|
676
|
+
"collection.tab.settings": "Settings",
|
|
677
|
+
"collection.notFound": "This collection doesn't exist. It may have been deleted.",
|
|
678
|
+
"collection.back": "Back to collections",
|
|
679
|
+
"entries.add": "Add {entry}",
|
|
680
|
+
"entries.empty": "There's nothing in {name} yet.",
|
|
681
|
+
"entries.column.title": "Title",
|
|
682
|
+
"entries.column.address": "Address",
|
|
683
|
+
"newEntry.title": "Add {entry}",
|
|
684
|
+
"newEntry.entryTitle": "Title",
|
|
685
|
+
"newEntry.address": "Address",
|
|
686
|
+
"newEntry.addressHint": "The last part of the page's address, as in {example}.",
|
|
687
|
+
"newEntry.message": "Add {entry} \"{title}\"",
|
|
688
|
+
"entryName.invalid": "Use lowercase letters, numbers and hyphens only, such as easter-vigil.",
|
|
689
|
+
"entryName.reserved": "This address is used by the admin panel. Choose another.",
|
|
690
|
+
"entryName.taken": "Something else on the site already uses this address.",
|
|
691
|
+
"moveEntry.message": "Move {entry} \"{title}\" from {from} to {to}",
|
|
692
|
+
"deleteEntry.body": "{title} will be removed from {name}.",
|
|
693
|
+
"deleteEntry.bodyPage": "{title} will be removed from {name}, and its page at {path} from the site. If anything on the site links to it, those links need changing first.",
|
|
694
|
+
"deleteEntry.message": "Delete {entry} \"{title}\"",
|
|
695
|
+
"editEntry.message": "Update {entry} \"{title}\"",
|
|
696
|
+
"editEntry.invalidSchedule": "Check when it's on the site: each time needs a date and a time, and it can only be hidden after it's shown.",
|
|
697
|
+
"editEntry.notFound": "This item doesn't exist. It may have been renamed or deleted.",
|
|
698
|
+
"editEntry.back": "Back to {name}",
|
|
699
|
+
"editEntry.required": "Fill in {fields} before publishing.",
|
|
700
|
+
"editEntry.invalidEvent": "Check {fields}: an event has to end after it starts.",
|
|
701
|
+
"editEntry.noPage": "Items in {name} don't have pages of their own. They appear wherever a Collection list block shows {name}.",
|
|
702
|
+
"template.help": "Every item's page uses this design. To show an item's information, add an Entry field block, or type a field's name in braces in any text:",
|
|
703
|
+
"template.message": "Update the page design for {name}",
|
|
704
|
+
"template.noPages": "Items in {name} don't have pages of their own, so there's no page design. To give them pages, turn that on in Settings.",
|
|
705
|
+
"collectionSettings.markdown": "Store items as Markdown files",
|
|
706
|
+
"collectionSettings.markdownHint": "Each item's text is saved as Markdown, a plain-text format that developers can also edit in their own tools. It's edited here as before.",
|
|
707
|
+
"collectionSettings.markdownNeedsText": "To store items as Markdown files, add a formatted-text field first.",
|
|
708
|
+
"collectionSettings.markdownBody": "Field saved as the Markdown text",
|
|
709
|
+
"collectionSettings.markdownConverts": "Every item will be converted when you publish.",
|
|
710
|
+
"collectionSettings.calendar": "Show items in calendars",
|
|
711
|
+
"collectionSettings.calendarHint": "Items become events that Calendar blocks show, which visitors can add to their own calendars or subscribe to.",
|
|
712
|
+
"collectionSettings.calendarNeedsDate": "To show items in calendars, add a \"Date and time, for events\" field first.",
|
|
713
|
+
"collectionSettings.calendarWhen": "When items happen",
|
|
714
|
+
"collectionSettings.calendarPlace": "Where",
|
|
715
|
+
"collectionSettings.calendarSummary": "Summary",
|
|
716
|
+
"collectionSettings.calendarNone": "None",
|
|
717
|
+
"event.starts": "Starts",
|
|
718
|
+
"event.ends": "Ends (optional)",
|
|
719
|
+
"event.time": "Time",
|
|
720
|
+
"event.allDay": "All day",
|
|
721
|
+
"event.repeats": "Repeats",
|
|
722
|
+
"event.repeat.none": "Doesn't repeat",
|
|
723
|
+
"event.repeat.day": "Daily",
|
|
724
|
+
"event.repeat.week": "Weekly",
|
|
725
|
+
"event.repeat.month": "Monthly",
|
|
726
|
+
"event.repeat.year": "Yearly",
|
|
727
|
+
"event.interval.days": "Every how many days",
|
|
728
|
+
"event.interval.weeks": "Every how many weeks",
|
|
729
|
+
"event.interval.months": "Every how many months",
|
|
730
|
+
"event.interval.years": "Every how many years",
|
|
731
|
+
"event.onDays": "On",
|
|
732
|
+
"event.monthlyOn": "On",
|
|
733
|
+
"event.monthly.date": "Day {day} of the month",
|
|
734
|
+
"event.monthly.weekday": "The {nth} {weekday} of the month",
|
|
735
|
+
"event.monthly.last": "The last {weekday} of the month",
|
|
736
|
+
"event.nth.1": "first",
|
|
737
|
+
"event.nth.2": "second",
|
|
738
|
+
"event.nth.3": "third",
|
|
739
|
+
"event.nth.4": "fourth",
|
|
740
|
+
"event.nth.5": "fifth",
|
|
741
|
+
"event.until": "Until (optional)",
|
|
742
|
+
"event.skip": "Dates it doesn't happen",
|
|
743
|
+
"event.skipDate": "A date it doesn't happen",
|
|
744
|
+
"event.addSkip": "Skip this date",
|
|
745
|
+
"event.next": "Next: {when}",
|
|
746
|
+
"event.when": "{when}",
|
|
747
|
+
"collectionSettings.general": "General",
|
|
748
|
+
"collectionSettings.path": "Address of each item's page",
|
|
749
|
+
"collectionSettings.pathHint": "{slug} stands for each item's own address, as in /videos/{slug}.",
|
|
750
|
+
"collectionSettings.pathWarning": "Changing this changes the address of every item's page, and links to them on this site change with it. Links to the old addresses on other websites will stop working.",
|
|
751
|
+
"collectionSettings.sortField": "Order items by",
|
|
752
|
+
"collectionSettings.sortOrder": "Direction",
|
|
753
|
+
"collectionSettings.ascending": "A to Z, oldest or smallest first",
|
|
754
|
+
"collectionSettings.descending": "Z to A, newest or largest first",
|
|
755
|
+
"collectionSettings.message": "Update the settings for {name}",
|
|
756
|
+
"collectionSettings.addressClash": "With this address, {title} would have the same address as another page.",
|
|
757
|
+
"collectionSettings.delete": "Delete collection",
|
|
758
|
+
"collectionSettings.deleteTitle": "Delete {name}?",
|
|
759
|
+
"collectionSettings.deleteBody": "{name} and everything in it ({count} items) will be removed from the site, along with their pages. Collection lists showing it will be empty.",
|
|
760
|
+
"collectionSettings.deleteMessage": "Delete collection \"{name}\"",
|
|
761
|
+
"fields.title": "Fields",
|
|
762
|
+
"fields.intro": "The information each item has. Every item has a title. A field's kind can't be changed once it's published, but you can add a new field instead.",
|
|
763
|
+
"fields.add": "Add field",
|
|
764
|
+
"fields.label": "Label",
|
|
765
|
+
"fields.type": "Kind of information",
|
|
766
|
+
"fields.required": "Must be filled in",
|
|
767
|
+
"fields.hint": "Help text",
|
|
768
|
+
"fields.options": "Choices",
|
|
769
|
+
"fields.option": "Choice {number}",
|
|
770
|
+
"fields.addOption": "Add choice",
|
|
771
|
+
"fields.optionsRequired": "Add at least one choice.",
|
|
772
|
+
"fields.placeholder": "Show it on the page design with {placeholder}",
|
|
773
|
+
"fields.remove": "Remove field",
|
|
774
|
+
"fields.removed": "Removed fields are also removed from every item when you publish.",
|
|
775
|
+
"fields.newLabel": "New field",
|
|
776
|
+
"fields.type.text": "Short text",
|
|
777
|
+
"fields.type.textarea": "Long text",
|
|
778
|
+
"fields.type.richtext": "Formatted text",
|
|
779
|
+
"fields.type.number": "Number",
|
|
780
|
+
"fields.type.date": "Date",
|
|
781
|
+
"fields.type.event": "Date and time, for events",
|
|
782
|
+
"fields.type.link": "Link",
|
|
783
|
+
"fields.type.image": "Image (URL)",
|
|
784
|
+
"fields.type.select": "Choice from a list",
|
|
785
|
+
"fields.type.tags": "Tags (any number of choices from a list)",
|
|
786
|
+
"demo.banner": "This is a demo. Try anything you like: your changes show here in the admin panel and are saved in this browser only. The site itself never changes.",
|
|
787
|
+
"demo.published": "Saved in this browser. The site itself doesn't change in a demo.",
|
|
788
|
+
"demo.startOver": "Start over",
|
|
789
|
+
"demo.startOverTitle": "Start over?",
|
|
790
|
+
"demo.startOverBody": "Everything you've changed in this demo, including uploads, will be undone.",
|
|
791
|
+
"blocks.contentOnly": "This site's blocks are added by its developer.",
|
|
792
|
+
"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.",
|
|
793
|
+
"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.",
|
|
794
|
+
"markdown.formatted": "Formatted",
|
|
795
|
+
"markdown.source": "Markdown",
|
|
796
|
+
"markdown.sourceOnly": "This text uses things the formatted view can't show, such as tables or pictures, so it can only be edited as Markdown.",
|
|
797
|
+
"blocks.title": "Blocks",
|
|
798
|
+
"blocks.intro": "Add blocks to the editor, or remove ones you don't use. A change shows in the editor once the site has been updated, usually within a few minutes.",
|
|
799
|
+
"blocks.otherRegistries": "Blocks from other registries can be added once the admin panel has an address of its own, where the site's pages can't reach it (Site Settings → Domain).",
|
|
800
|
+
"blocks.installed": "Added to this site",
|
|
801
|
+
"blocks.noneInstalled": "No blocks have been added yet. The editor offers the built-in ones.",
|
|
802
|
+
"blocks.loading": "Loading the blocks you can add…",
|
|
803
|
+
"blocks.recommended": "Recommended",
|
|
804
|
+
"blocks.add": "Add",
|
|
805
|
+
"blocks.addLabel": "Add {name}",
|
|
806
|
+
"blocks.added": "Added",
|
|
807
|
+
"blocks.remove": "Remove",
|
|
808
|
+
"blocks.removeLabel": "Remove {name}",
|
|
809
|
+
"blocks.undo": "Undo",
|
|
810
|
+
"blocks.undoAddLabel": "Don't add {name}",
|
|
811
|
+
"blocks.undoRemoveLabel": "Don't remove {name}",
|
|
812
|
+
"blocks.willAdd": "Will be added",
|
|
813
|
+
"blocks.willRemove": "Will be removed",
|
|
814
|
+
"blocks.pendingTitle": "Changes to publish",
|
|
815
|
+
"blocks.pendingAdd": "Add {name}",
|
|
816
|
+
"blocks.pendingRemove": "Remove {name}",
|
|
817
|
+
"blocks.discard": "Discard changes",
|
|
818
|
+
"blocks.publish": "Publish",
|
|
819
|
+
"blocks.publishedNotice": "Published. Added blocks will be in the editor's list of blocks, and removed ones gone from it, once the site has been updated.",
|
|
820
|
+
"blocks.addMessage": "Add the {name} block",
|
|
821
|
+
"blocks.removeMessage": "Remove the {name} block",
|
|
822
|
+
"blocks.changeMessage": "Change blocks: {changes}",
|
|
823
|
+
"blocks.addPart": "add {names}",
|
|
824
|
+
"blocks.removePart": "remove {names}",
|
|
825
|
+
"blocks.inUseTitle": "{name} can't be removed yet",
|
|
826
|
+
"blocks.inUse": "{name} is used in: {places}. Take it out of those first, then remove it.",
|
|
827
|
+
"blocks.usedTemplate": "the page design of {name}",
|
|
828
|
+
"blocks.error.record": "The list of blocks added to this site couldn't be read. Tell whoever looks after the site.",
|
|
829
|
+
"blocks.error.unreachable": "The blocks you can add couldn't be loaded. Check your internet connection and try again.",
|
|
830
|
+
"blocks.error.invalid": "The list of blocks you can add has a problem, so it couldn't be read. Try again later.",
|
|
831
|
+
"blocks.error.untrusted": "This block comes from somewhere this site doesn't trust, so it can't be added.",
|
|
832
|
+
"blocks.error.not-a-block": "This can't be added to the editor as a block.",
|
|
833
|
+
"blocks.error.already-installed": "{name} has already been added.",
|
|
834
|
+
"blocks.error.name-taken": "The site already has a block with the same name as {name}, so it can't be added.",
|
|
835
|
+
"blocks.error.packages": "This block needs software the site doesn't have ({packages}). Ask whoever looks after the site to add it.",
|
|
836
|
+
"blocks.error.unsupported-file": "This block includes a file that can't be added from here ({path}). Ask whoever looks after the site to add it.",
|
|
837
|
+
"blocks.error.file-exists": "The site already has a different file where this block goes ({path}). Ask whoever looks after the site to add it.",
|
|
838
|
+
"blocks.error.not-installed": "{name} hasn't been added, so there's nothing to remove.",
|
|
839
|
+
"blocks.error.in-use": "{name} is still used on the site. Take it out first, then remove it.",
|
|
840
|
+
"media.title": "Media",
|
|
841
|
+
"media.cloudflareVideo": "This site is published with Cloudflare Pages, whose terms limit serving video and other large files on its free plans without one of its paid services. To be safe, put videos on YouTube or Vimeo and show them with the Video block, which plays them from there.",
|
|
842
|
+
"media.cloudflareVideoTerms": "Cloudflare's terms",
|
|
843
|
+
"media.intro": "Images and files for the site. Upload them here, or while editing a page. Large photos are made smaller, and details hidden in photos, such as where they were taken, are removed.",
|
|
844
|
+
"media.upload": "Upload files",
|
|
845
|
+
"media.uploading": "Uploading…",
|
|
846
|
+
"media.drop": "Drop files here to upload them",
|
|
847
|
+
"media.empty": "There are no images or files yet.",
|
|
848
|
+
"media.search": "Find a file",
|
|
849
|
+
"media.noMatches": "No files match.",
|
|
850
|
+
"media.copy": "Copy address",
|
|
851
|
+
"media.copied": "Copied",
|
|
852
|
+
"media.replace": "Replace",
|
|
853
|
+
"media.delete": "Delete",
|
|
854
|
+
"media.uploaded": "Uploaded. Visitors see new files once the live site is updated.",
|
|
855
|
+
"media.uploadMessage": "Add {names}",
|
|
856
|
+
"media.replaceMessage": "Replace {name}",
|
|
857
|
+
"media.deleteMessage": "Delete {name}",
|
|
858
|
+
"media.deleteTitle": "Delete {name}?",
|
|
859
|
+
"media.deleteBody": "{name} will be removed from the site.",
|
|
860
|
+
"media.deleteUsed": "It's used in: {places}. Choose another file there first, since the site can't be published using a file that's gone.",
|
|
861
|
+
"media.notUsed": "Nothing on the site uses it.",
|
|
862
|
+
"media.error.type": "{name} can't be uploaded. The site can use images (JPEG, PNG, GIF, WebP, AVIF and SVG), PDFs, office documents, MP3 audio, MP4 and WebM video, and captions for videos (WebVTT).",
|
|
863
|
+
"media.error.size": "{name} is too large: files can be up to 25 MB. For long videos, upload them to YouTube or Vimeo and link to them instead.",
|
|
864
|
+
"media.error.unreadable": "{name} couldn't be read. It may be damaged, or in a format browsers can't open, such as HEIC. Save it as a JPEG and try again.",
|
|
865
|
+
"media.choose": "Choose image",
|
|
866
|
+
"media.chooseFile": "Choose file",
|
|
867
|
+
"media.chooseVideo": "Choose video",
|
|
868
|
+
"media.pickerTitle": "Choose an image",
|
|
869
|
+
"media.pickerTitleFile": "Choose a file",
|
|
870
|
+
"media.pickerTitleVideo": "Choose a video",
|
|
871
|
+
"media.pickerEmpty": "Nothing has been uploaded yet. Upload a file to use it here.",
|
|
872
|
+
"media.clear": "Remove",
|
|
873
|
+
"media.credit": "Credit: {credit}",
|
|
874
|
+
"ai.label": "AI",
|
|
875
|
+
"ai.title": "Write with AI",
|
|
876
|
+
"ai.mode.add": "Describe what to add to this page, and AI will write it at the end of the page.",
|
|
877
|
+
"ai.mode.edit": "Describe how to change the selected {block}.",
|
|
878
|
+
"ai.mode.fill": "Describe this {item}, or paste your notes, and AI will fill in its fields.",
|
|
879
|
+
"ai.instruction": "What should it write?",
|
|
880
|
+
"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.",
|
|
881
|
+
"ai.placeholder.edit": "For example: make it shorter and friendlier, or translate it into Spanish.",
|
|
882
|
+
"ai.placeholder.fill": "For example: Fr. Smith's homily from Sunday, about hope. Paste any notes here.",
|
|
883
|
+
"ai.photos": "Find openly licensed photos where the media library has none that fit",
|
|
884
|
+
"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.",
|
|
885
|
+
"ai.photos.finding": "Finding photos…",
|
|
886
|
+
"ai.photos.message": "Add photos from Openverse: {names}",
|
|
887
|
+
"ai.photos.added": "Photos from Openverse added to the media library: {names}.",
|
|
888
|
+
"ai.photos.missing": "No photo was found for {count} of the images, so they're left empty.",
|
|
889
|
+
"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.",
|
|
890
|
+
"ai.photos.error.unreachable": "Couldn't reach Openverse, so some images are left empty. Check your connection, or choose them from the media library.",
|
|
891
|
+
"ai.photos.error.publish": "The photos found couldn't be added to the media library, so their images are left empty.",
|
|
892
|
+
"ai.translate": "Translate into {language}",
|
|
893
|
+
"ai.translate.hint": "This page is in {language}. The AI can translate all of it, blocks and settings, keeping its layout. Anything you write below is passed on too, such as how formal to be.",
|
|
894
|
+
"ai.translate.entryHint": "This item is in {language}. The AI can translate all its fields. Anything you write below is passed on too.",
|
|
895
|
+
"ai.write": "Write it",
|
|
896
|
+
"ai.change": "Change it",
|
|
897
|
+
"ai.fill": "Fill it in",
|
|
898
|
+
"ai.stop": "Stop",
|
|
899
|
+
"ai.working": "Writing…",
|
|
900
|
+
"ai.progress": "Writing… {count} characters so far",
|
|
901
|
+
"ai.done": "Done. Check it over, then publish when you're happy. Undo takes it back.",
|
|
902
|
+
"ai.check": "AI can get things wrong, so read what it writes before publishing. It won't make up times, names or prices: it leaves [placeholders] for you to fill in.",
|
|
903
|
+
"ai.needsKey": "To use {service}, add an API key under the AI service settings below, or choose another service.",
|
|
904
|
+
"ai.settings": "AI service: {service}",
|
|
905
|
+
"ai.provider": "AI service",
|
|
906
|
+
"ai.provider.manual": "Copy and paste into a chat app (free)",
|
|
907
|
+
"ai.provider.custom": "Another service (OpenAI-compatible)",
|
|
908
|
+
"ai.noKey": "(free, no key needed)",
|
|
909
|
+
"ai.siteKey": "(provided by this site)",
|
|
910
|
+
"ai.siteKeyHint": "This site provides {service}, so you don't need a key of your own.",
|
|
911
|
+
"ai.baseUrl": "Address of the service's API",
|
|
912
|
+
"ai.baseUrlHint": "Such as https://example.org/v1.",
|
|
913
|
+
"ai.key": "API key",
|
|
914
|
+
"ai.keyHint": "Your key stays in this browser and is only ever sent to {service}.",
|
|
915
|
+
"ai.getKey": "Get a key from {service}",
|
|
916
|
+
"ai.remember": "Remember the key on this device",
|
|
917
|
+
"ai.forget": "Forget the key",
|
|
918
|
+
"ai.costHint": "{service} may charge the account the key belongs to for each request. Setting a spending limit there is a good idea.",
|
|
919
|
+
"ai.model": "Model",
|
|
920
|
+
"ai.modelHint": "Choose one from the list, or type its name.",
|
|
921
|
+
"ai.model.claude-opus-5-5": "Claude Opus 5.5 (best results)",
|
|
922
|
+
"ai.model.claude-sonnet-5-5": "Claude Sonnet 5.5 (faster, costs less)",
|
|
923
|
+
"ai.model.claude-haiku-4-5": "Claude Haiku 4.5 (fastest, costs least)",
|
|
924
|
+
"ai.manual.step1": "Copy this request into Claude.ai, ChatGPT or another chat app:",
|
|
925
|
+
"ai.manual.copy": "Copy the request",
|
|
926
|
+
"ai.manual.copied": "Copied",
|
|
927
|
+
"ai.manual.step2": "Then paste the chat app's whole answer here:",
|
|
928
|
+
"ai.manual.use": "Use this answer",
|
|
929
|
+
"ai.error.key": "{service} didn't accept the API key. Check that it was copied in full, that it hasn't been deleted, and that it can use this model.",
|
|
930
|
+
"ai.error.limit": "{service} is limiting requests right now, or the account is out of credit. Wait a minute and try again, or choose another service.",
|
|
931
|
+
"ai.error.network": "Couldn't reach {service}. Check your connection and try again.",
|
|
932
|
+
"ai.error.refused": "{service} declined to write this. Try asking a different way.",
|
|
933
|
+
"ai.error.answer": "The answer couldn't be used. Try again, or try a different model or service.",
|
|
934
|
+
"ai.error.service": "{service} reported a problem. Try again in a moment.",
|
|
935
|
+
"settings.title": "Site Settings",
|
|
936
|
+
"appearance.choice": "Colors of the admin panel",
|
|
937
|
+
"appearance.system": "Match my computer",
|
|
938
|
+
"appearance.light": "Light",
|
|
939
|
+
"appearance.dark": "Dark",
|
|
940
|
+
"settings.tab.general": "General",
|
|
941
|
+
"settings.tab.languages": "Languages",
|
|
942
|
+
"settings.tab.theme": "Colors & fonts",
|
|
943
|
+
"settings.tab.menus": "Menus",
|
|
944
|
+
"settings.tab.css": "Custom CSS",
|
|
945
|
+
"settings.tab.code": "Code",
|
|
946
|
+
"settings.tab.redirects": "Redirects",
|
|
947
|
+
"settings.sidebar.collapse": "Hide names",
|
|
948
|
+
"settings.sidebar.expand": "Show names",
|
|
949
|
+
"settings.tab.domain": "Domain",
|
|
950
|
+
"settings.message": "Update site settings",
|
|
951
|
+
"settings.preview": "Preview of the home page",
|
|
952
|
+
"settings.previewEmpty": "Add a home page to see a preview here.",
|
|
953
|
+
"domain.intro": "Connect a domain you own, such as example.org, so the site is at that address.",
|
|
954
|
+
"domain.current": "The site is at {address} now.",
|
|
955
|
+
"domain.connected": "Connected to {domain}.",
|
|
956
|
+
"domain.signIn": "Editors sign in by going to {host} and coming back here. Add {address} to the redirect URIs of the application they sign in with on {host}, or signing in will stop working once the site moves.",
|
|
957
|
+
"domain.local": "Domains are connected in the published site's admin panel, where you sign in with GitHub or GitLab.",
|
|
958
|
+
"domain.admin.title": "Admin address",
|
|
959
|
+
"domain.admin.intro": "An address of its own for the admin panel, such as admin.example.org, keeps the site's pages from reaching what the admin panel keeps, such as editors' sign-in. Editors then sign in there. Blocks from registries other than Goodfellow's can be added only once the admin panel has one.",
|
|
960
|
+
"domain.admin.unsupported": "{host} serves each site at one address, so the admin panel can't have one of its own here.",
|
|
961
|
+
"domain.admin.contentOnly": "The admin panel's address is named in the site's code, which is kept apart from its content, so a developer finishes setting it up there.",
|
|
962
|
+
"domain.admin.code.unknown": "This sign-in can't read the site's code, so this screen can't tell whether a developer has done that yet.",
|
|
963
|
+
"domain.admin.code.add": "A developer names the address in the site's code, then publishes the code:",
|
|
964
|
+
"domain.admin.code.check": "Once the site has been rebuilt and works at {address}, the developer checks the address. From the next rebuild, the admin panel opens only there:",
|
|
965
|
+
"domain.admin.code.remove": "To stop using it, a developer removes it from the site's code:",
|
|
966
|
+
"domain.admin.noConfig": "The admin panel's address goes in the site's {file}, which doesn't have the usual defineConfig({ for it, so a developer sets it there.",
|
|
967
|
+
"domain.admin.noSiteAddress": "Connect the site's own domain first: the admin address goes beside it.",
|
|
968
|
+
"domain.admin.field": "Admin address",
|
|
969
|
+
"domain.admin.fieldHint": "An address of the site's domain that nothing else uses, such as admin.example.org.",
|
|
970
|
+
"domain.admin.connect": "Give the admin panel this address",
|
|
971
|
+
"domain.admin.elsewhere": "Add this address to the site's project on {host} too, under its custom domains, so it serves the site there as well. The site's build writes {host}'s rules for it.",
|
|
972
|
+
"domain.admin.message": "Give the admin panel its own address, {domain}",
|
|
973
|
+
"domain.admin.connected": "The admin panel's address is {domain}",
|
|
974
|
+
"domain.admin.ready": "The site answers at {address}. Open the admin panel there to start using it.",
|
|
975
|
+
"domain.admin.pending": "Once the site has been rebuilt and works at {address}, open the admin panel there: it checks the address, then you can start using it.",
|
|
976
|
+
"domain.admin.open": "Open the admin panel at {address}",
|
|
977
|
+
"domain.admin.signInAgain": "You'll sign in again there: it's a different address, so it keeps its own sign-in.",
|
|
978
|
+
"domain.admin.checking": "Checking {address}…",
|
|
979
|
+
"domain.admin.check.ok": "The site's pages at this address leave it for the site's own address before running anything.",
|
|
980
|
+
"domain.admin.check.no-guard": "The site's pages at this address don't start with the script that sends them away yet. Wait for the site to be rebuilt, then check again.",
|
|
981
|
+
"domain.admin.check.no-sandbox": "{host} doesn't sandbox the site's pages at this address yet. Wait for the site to be rebuilt, then check again.",
|
|
982
|
+
"domain.admin.check.failed": "The admin panel couldn't check this address. Try again in a moment.",
|
|
983
|
+
"domain.admin.use": "Start using {address}",
|
|
984
|
+
"domain.admin.useMessage": "Open the admin panel only at {domain}",
|
|
985
|
+
"domain.admin.using": "The admin panel's address is {address}. Once the site has been rebuilt, the admin panel opens only there.",
|
|
986
|
+
"domain.admin.stop": "Stop using the admin address",
|
|
987
|
+
"domain.admin.stopMessage": "Stop using {domain} for the admin panel",
|
|
988
|
+
"domain.admin.stopBody": "The admin panel goes back to the site's own address once the site has been rebuilt, and blocks from registries other than Goodfellow's can't be added there.",
|
|
989
|
+
"domain.demo": "Domains can't be connected in a demo.",
|
|
990
|
+
"domain.loading": "Finding where the site is published…",
|
|
991
|
+
"domain.elsewhere": "This site isn't published with {host} Pages, so its domain is connected where it is published.",
|
|
992
|
+
"domain.elsewhere.cloudflare": "On Cloudflare Pages: open the site's project, go to Custom domains and set up the domain. For a domain such as www.example.org, Cloudflare shows a CNAME record to add at your registrar. For example.org itself, the domain has to use Cloudflare's nameservers, which Cloudflare walks you through. Once the site works at its new address, put the address in General → Site address and publish.",
|
|
993
|
+
"domain.elsewhere.vercel": "On Vercel: open the site's project, go to Settings → Domains and add the domain. Vercel shows the records to add at your registrar. Once the site works at its new address, put the address in General → Site address and publish.",
|
|
994
|
+
"domain.field": "Domain",
|
|
995
|
+
"domain.fieldHint": "Such as example.org or www.example.org. Buy one first from a registrar, such as Cloudflare, Namecheap or Porkbun. For example.org, www.example.org works too.",
|
|
996
|
+
"domain.invalid": "That isn't a domain. Type it like example.org.",
|
|
997
|
+
"domain.warning": "Once it's connected, the site's current address sends visitors to the new one, which works only once the records are in place. Have your registrar's DNS settings open, and add the records straight away.",
|
|
998
|
+
"domain.connect": "Connect domain",
|
|
999
|
+
"domain.connecting": "Connecting…",
|
|
1000
|
+
"domain.message": "Use the domain {domain}",
|
|
1001
|
+
"domain.records.title": "1. Add these records at your registrar",
|
|
1002
|
+
"domain.records.hint": "Your registrar is where you bought the domain. In its DNS settings, add each record below. Delete any other A, AAAA or CNAME records with the same names, such as a page the registrar shows on new domains.",
|
|
1003
|
+
"domain.records.type": "Type",
|
|
1004
|
+
"domain.records.name": "Name",
|
|
1005
|
+
"domain.records.value": "Value",
|
|
1006
|
+
"domain.records.state": "Found",
|
|
1007
|
+
"domain.records.ok": "In place",
|
|
1008
|
+
"domain.records.missing": "Not there yet",
|
|
1009
|
+
"domain.records.wrong": "Something else is there: {found}",
|
|
1010
|
+
"domain.records.optional": "optional",
|
|
1011
|
+
"domain.purpose.site": "Points the domain at the site.",
|
|
1012
|
+
"domain.purpose.verification": "Proves to {host} that the domain is yours. Leave it in place.",
|
|
1013
|
+
"domain.purpose.www": "Sends www. to the site too.",
|
|
1014
|
+
"domain.guides": "How to add records at:",
|
|
1015
|
+
"domain.cloudflare": "On Cloudflare, set each record's proxy status to DNS only, or {host} can't set up HTTPS for the domain.",
|
|
1016
|
+
"domain.host.title": "2. {host} checks the domain and sets up HTTPS",
|
|
1017
|
+
"domain.host.unverified": "Waiting for {host} to find the record that proves the domain is yours.",
|
|
1018
|
+
"domain.host.pending": "Waiting for the HTTPS certificate, which {host} asks for once the records are in place. It can take up to a day.",
|
|
1019
|
+
"domain.host.ready": "The HTTPS certificate is ready.",
|
|
1020
|
+
"domain.host.failed": "The HTTPS certificate couldn't be set up. Check the records, then remove the domain and connect it again.",
|
|
1021
|
+
"domain.site.title": "3. The site opens at {address}",
|
|
1022
|
+
"domain.site.waiting": "Not yet. New records can take from a few minutes to a day to reach everyone.",
|
|
1023
|
+
"domain.site.ok": "It does.",
|
|
1024
|
+
"domain.secure": "Send everyone to {address}",
|
|
1025
|
+
"domain.secureHint": "Visitors always get the secure address, and the site's old address sends them there too.",
|
|
1026
|
+
"domain.done": "Done: the site is at {address}.",
|
|
1027
|
+
"domain.check": "Check again",
|
|
1028
|
+
"domain.checking": "Checking…",
|
|
1029
|
+
"domain.checkFailed": "Couldn't check the records just now. Try again in a moment.",
|
|
1030
|
+
"domain.remove": "Remove domain",
|
|
1031
|
+
"domain.removeTitle": "Remove {domain}?",
|
|
1032
|
+
"domain.removeBody": "The site goes back to its address on {host}. Delete the records at your registrar afterwards, so nobody else can use the domain for a site.",
|
|
1033
|
+
"domain.removeMessage": "Stop using the domain {domain}",
|
|
1034
|
+
"domain.error.not-allowed": "Your account can't change the site's {host} Pages settings. Ask one of the site's owners to make you an owner too, or to connect the domain for you.",
|
|
1035
|
+
"domain.error.taken": "Another site on {host} already uses this domain. If it's yours too, remove it there first.",
|
|
1036
|
+
"domain.error.invalid": "{host} doesn't accept this domain.",
|
|
1037
|
+
"domain.error.not-ready": "The HTTPS certificate isn't ready yet. Try again in a while.",
|
|
1038
|
+
"domain.error.other": "Something went wrong.",
|
|
1039
|
+
"owner.signIn.title": "Sign in as an owner",
|
|
1040
|
+
"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.",
|
|
1041
|
+
"owner.signIn.button": "Sign in as an owner with {host}",
|
|
1042
|
+
"owner.signIn.orToken": "Or use an owner token",
|
|
1043
|
+
"owner.token.title": "Use an owner token",
|
|
1044
|
+
"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.",
|
|
1045
|
+
"owner.token.create": "Create an owner token on {host}",
|
|
1046
|
+
"owner.token.chooseSite": "If {host} asks what the token can access, choose only this site.",
|
|
1047
|
+
"owner.token.paste": "Copy the token and paste it here:",
|
|
1048
|
+
"settings.tab.security": "Security",
|
|
1049
|
+
"security.demo": "A demo has no sign-in to keep safe.",
|
|
1050
|
+
"security.local": "The sign-in page is set in the published site's admin panel, where you sign in with GitHub or GitLab.",
|
|
1051
|
+
"security.contentOnly": "The sign-in page is set in the site's code, which is kept apart from its content, so a developer sets it there.",
|
|
1052
|
+
"security.broker.title": "Sign-in page",
|
|
1053
|
+
"security.broker.intro": "A sign-in page of its own, called a token broker, keeps editors' sign-in and AI keys at an address that holds nothing else, and shows the admin panel from there, so nothing on the site's pages can reach them. It asks the editor before publishing the site's code. It needs an address of its own, such as signin.example.org, that serves nothing else.",
|
|
1054
|
+
"security.broker.useTitle": "Sign in at a sign-in page that's online",
|
|
1055
|
+
"security.add.title": "Add a token broker",
|
|
1056
|
+
"security.add.intro": "Makes a project on {host} for the sign-in page, from Goodfellow's released files and a file naming this site, and puts it online where you choose. It needs a token that can create projects, used only now and kept only until you leave this screen.",
|
|
1057
|
+
"security.add.place": "Where to put it online",
|
|
1058
|
+
"security.add.place.gitlab-pages": "GitLab Pages, at an address GitLab gives it",
|
|
1059
|
+
"security.add.place.gitlab-domain": "GitLab Pages, at a domain of yours",
|
|
1060
|
+
"security.add.place.github-domain": "GitHub Pages, at a domain of yours",
|
|
1061
|
+
"security.add.place.cloudflare-pages": "Cloudflare Pages",
|
|
1062
|
+
"security.add.place.vercel": "Vercel",
|
|
1063
|
+
"security.add.placeHint.gitlab-pages": "GitLab gives the project an address of its own, which nothing else shares. You'll find it under Deploy → Pages once it's online. No domain needed.",
|
|
1064
|
+
"security.add.placeHint.gitlab-domain": "A domain, or part of one, such as signin.example.org, that you point at GitLab Pages as its settings say.",
|
|
1065
|
+
"security.add.placeHint.github-domain": "GitHub Pages' own addresses are shared by every site in the same account, so the sign-in page needs a domain, or part of one, such as signin.example.org, which you point at GitHub Pages as its settings say.",
|
|
1066
|
+
"security.add.placeHint.cloudflare-pages": "Free, with an address of its own. Once the project is made, connect it to a Cloudflare Pages project, as its README says.",
|
|
1067
|
+
"security.add.placeHint.vercel": "Free for non-commercial sites, with an address of its own. Once the project is made, import it as a Vercel project, as its README says.",
|
|
1068
|
+
"security.add.domain": "Its domain",
|
|
1069
|
+
"security.add.domainHint": "Such as signin.example.org. It serves only the sign-in page.",
|
|
1070
|
+
"security.add.problem.domain": "That doesn't look like a domain, such as signin.example.org.",
|
|
1071
|
+
"security.add.problem.shared": "github.io and gitlab.io addresses are shared with other sites, so the sign-in page needs a domain of yours.",
|
|
1072
|
+
"security.add.problem.site": "That's the site's own address. The sign-in page needs one of its own.",
|
|
1073
|
+
"security.add.name": "Its name on {host}",
|
|
1074
|
+
"security.add.nameHint": "Made beside the site's on {host}, public, since it holds only Goodfellow's released files.",
|
|
1075
|
+
"security.add.token": "A {host} token that can create projects",
|
|
1076
|
+
"security.add.tokenHint": "Used only to make the project, and forgotten when you leave this screen.",
|
|
1077
|
+
"security.add.create": "Make the sign-in page",
|
|
1078
|
+
"security.add.creating": "Making the sign-in page…",
|
|
1079
|
+
"security.add.description": "The sign-in page for {site}",
|
|
1080
|
+
"security.add.message": "Add the sign-in page",
|
|
1081
|
+
"security.add.error.token": "{host} didn't accept that token. Check it's whole, and that it can create projects.",
|
|
1082
|
+
"security.add.error.owner": "That token can't create projects anywhere.",
|
|
1083
|
+
"security.add.error.taken": "There's already a project with that name. Choose another.",
|
|
1084
|
+
"security.add.error.release": "Goodfellow's sign-in page couldn't be downloaded. Try again in a moment.",
|
|
1085
|
+
"security.add.error.name-taken": "There's already a project with that name. Choose another.",
|
|
1086
|
+
"security.add.error.name-invalid": "{host} can't use that as a project's name.",
|
|
1087
|
+
"security.add.error.not-allowed": "That token can't create projects, or write their build setup.",
|
|
1088
|
+
"security.add.error.not-installed": "{host} needs the sign-in service's app installed there first.",
|
|
1089
|
+
"security.add.error.other": "The sign-in page couldn't be made.",
|
|
1090
|
+
"security.added.project": "The sign-in page's project is ready:",
|
|
1091
|
+
"security.added.github-pages": "GitHub Pages is putting it online. Point the domain at GitHub Pages as the project's Settings → Pages says, and wait for its certificate.",
|
|
1092
|
+
"security.added.gitlab-pages": "GitLab Pages is putting it online. Its address is under Deploy → Pages in the new project once the first build has finished.",
|
|
1093
|
+
"security.added.cloudflare-pages": "Now connect it to Cloudflare Pages: create a Pages project from the project, with no build command and site as the output directory.",
|
|
1094
|
+
"security.added.vercel": "Now import it as a Vercel project, with site as its root directory and no build command.",
|
|
1095
|
+
"security.added.domainUnavailable": "The domain couldn't be set for it, perhaps because another site has it. Set it in the project's Pages settings.",
|
|
1096
|
+
"security.added.pagesUnavailable": "Pages couldn't be turned on for it. Turn them on in the project's settings.",
|
|
1097
|
+
"security.added.address": "Once it opens at {address}, choose Sign in there from now on below. Editors sign in there once the site has rebuilt.",
|
|
1098
|
+
"security.added.then": "Once it's online, type its address below and choose Sign in there from now on. Editors sign in there once the site has rebuilt.",
|
|
1099
|
+
"security.broker.field": "The sign-in page's address",
|
|
1100
|
+
"security.broker.fieldHint": "An address that serves only the token broker, such as https://broker.example.org.",
|
|
1101
|
+
"security.broker.problem.invalid": "That needs to be an https:// address of its own, with nothing after the domain.",
|
|
1102
|
+
"security.broker.problem.shared": "github.io addresses are shared with every site in the same account, so the sign-in page needs a domain of its own.",
|
|
1103
|
+
"security.broker.problem.same-as-site": "That's the site's own address. The sign-in page needs one of its own.",
|
|
1104
|
+
"security.broker.redirect": "Editors sign in by going to {host} and coming back, so add the sign-in page's address to the redirect URIs of the application they sign in with on {host}.",
|
|
1105
|
+
"security.broker.use": "Sign in there from now on",
|
|
1106
|
+
"security.broker.message": "Sign in at {address}",
|
|
1107
|
+
"security.broker.error": "The sign-in page's address couldn't be published.",
|
|
1108
|
+
"security.broker.pending": "Once the site has been rebuilt, editors sign in at {address}, and the admin panel opens only there.",
|
|
1109
|
+
"security.broker.using": "Editors sign in at {address}, which keeps their sign-in and shows the admin panel.",
|
|
1110
|
+
"security.broker.stop": "Stop using the sign-in page",
|
|
1111
|
+
"security.broker.stopMessage": "Stop signing in at {address}",
|
|
1112
|
+
"security.broker.stopBody": "Once the site has been rebuilt, editors sign in in the admin panel itself again, which keeps their sign-in where the site's pages could reach it, and blocks from registries other than Goodfellow's can't be added there.",
|
|
1113
|
+
"broker.elsewhere": "Editors sign in to this site on its own sign-in page, which keeps your sign-in where the site's pages can't reach it, and shows the admin panel from there.",
|
|
1114
|
+
"broker.open": "Go to the sign-in page",
|
|
1115
|
+
"broker.invalid": "The site's config names a sign-in page whose address isn't one of its own, so the admin panel can't open. Ask the site's developer to fix the broker address in goodfellow.config.tsx.",
|
|
1116
|
+
"owner.token.pasteInBroker": "Copy the token, choose Use this token, and paste it where the sign-in page around the admin panel asks for it. It keeps the token, so the admin panel never sees it.",
|
|
1117
|
+
"owner.token.label": "Owner token",
|
|
1118
|
+
"owner.token.use": "Use this token",
|
|
1119
|
+
"owner.token.checking": "Checking…",
|
|
1120
|
+
"owner.token.error": "That token can't manage this site. Check that it was made by one of the site's owners, that it can access this site, and that the whole token was copied.",
|
|
1121
|
+
"owner.token.active": "This tab is using an owner token.",
|
|
1122
|
+
"owner.token.forget": "Stop using it",
|
|
1123
|
+
"settings.tab.editors": "Editors",
|
|
1124
|
+
"settings.tab.updates": "Updates",
|
|
1125
|
+
"settings.tab.history": "Site history",
|
|
1126
|
+
"settings.tab.import": "WordPress",
|
|
1127
|
+
"siteHistory.intro": "Every publish that changed the site's pages, items, settings or media, newest first. Choose one to see how the site looked after it and what has changed since, then restore the whole site as it was.",
|
|
1128
|
+
"siteHistory.local": "Site history is in the published site's admin panel, where you sign in with GitHub or GitLab.",
|
|
1129
|
+
"siteHistory.demo": "Site history isn't kept in a demo.",
|
|
1130
|
+
"siteHistory.error": "The site's history couldn't be loaded. Check your connection and try again.",
|
|
1131
|
+
"siteHistory.current": "The site now",
|
|
1132
|
+
"siteHistory.preview": "The site on {date}",
|
|
1133
|
+
"siteHistory.loadingVersion": "Loading the site as it was…",
|
|
1134
|
+
"siteHistory.readError": "The site as it was then couldn't be loaded. Check your connection and try again.",
|
|
1135
|
+
"siteHistory.unfit": "The site as it was then can't be shown or restored, because it doesn't fit the site as it is now.",
|
|
1136
|
+
"siteHistory.unknownBlocks": "The site as it was then can't be restored, because it uses blocks the site doesn't have any more: {blocks}.",
|
|
1137
|
+
"siteHistory.same": "Nothing has changed since: this is how the site is now.",
|
|
1138
|
+
"siteHistory.since": "What has changed since",
|
|
1139
|
+
"siteHistory.changed": "Changed since (restoring puts them back as they were):",
|
|
1140
|
+
"siteHistory.added": "Added since (restoring removes them):",
|
|
1141
|
+
"siteHistory.removed": "Removed since (restoring brings them back):",
|
|
1142
|
+
"siteHistory.page": "Page to look at",
|
|
1143
|
+
"siteHistory.restore": "Restore the site as it was",
|
|
1144
|
+
"siteHistory.restoreTitle": "Restore the site as it was on {date}?",
|
|
1145
|
+
"siteHistory.restoreBody": "Everything listed ({count}) goes back to how it was then, in one publish. The site as it is now stays in the history, so you can restore it again later. The site's code and update settings stay as they are.",
|
|
1146
|
+
"siteHistory.restoreMessage": "Restore the site as it was on {date}",
|
|
1147
|
+
"siteHistory.restored": "Restored the site as it was on {date}.",
|
|
1148
|
+
"siteHistory.subject.page": "{title} ({path})",
|
|
1149
|
+
"siteHistory.subject.entry": "{title}, in {collection}",
|
|
1150
|
+
"siteHistory.subject.collection": "The settings and page design of {name}",
|
|
1151
|
+
"siteHistory.subject.settings": "Site settings",
|
|
1152
|
+
"siteHistory.subject.menus": "Menus",
|
|
1153
|
+
"siteHistory.subject.redirects": "Redirects",
|
|
1154
|
+
"siteHistory.subject.credits": "Photo credits",
|
|
1155
|
+
"siteHistory.subject.css": "Custom CSS",
|
|
1156
|
+
"siteHistory.subject.code": "Code added to every page",
|
|
1157
|
+
"import.title": "Import from WordPress",
|
|
1158
|
+
"import.intro": "Bring a WordPress site's pages, posts, categories, tags, menus and media into this site. Pages keep their addresses, posts become a collection, and old addresses send visitors on to the new ones. You'll see what comes across before anything changes.",
|
|
1159
|
+
"import.file": "WordPress export file",
|
|
1160
|
+
"import.fileHint": "In WordPress, go to Tools → Export, choose All content and download the file. It brings the menus too.",
|
|
1161
|
+
"import.address": "Or the WordPress site's address",
|
|
1162
|
+
"import.addressHint": "Such as example.org. Only what visitors can see is read, and menus can't be.",
|
|
1163
|
+
"import.read": "Read the site",
|
|
1164
|
+
"import.reading": "Reading the WordPress site…",
|
|
1165
|
+
"import.ready": "Ready to import {title}",
|
|
1166
|
+
"import.summary.pages": "Pages: {count}",
|
|
1167
|
+
"import.summary.posts": "Posts: {count}, at {path}, listed on {list}",
|
|
1168
|
+
"import.summary.terms": "Categories: {categories}. Tags: {tags}.",
|
|
1169
|
+
"import.summary.menus": "Menus: {count}",
|
|
1170
|
+
"import.summary.redirects": "Old addresses sent on to new ones: {count}",
|
|
1171
|
+
"import.summary.media": "Media files to copy: {count}",
|
|
1172
|
+
"import.replaces": "These pages will be replaced: {pages}. Version history keeps the old ones.",
|
|
1173
|
+
"import.check": "Check these",
|
|
1174
|
+
"import.start": "Import",
|
|
1175
|
+
"import.another": "Choose another site",
|
|
1176
|
+
"import.copying": "Copying media: {done} of {total}…",
|
|
1177
|
+
"import.publishing": "Publishing…",
|
|
1178
|
+
"import.done": "Imported {title}.",
|
|
1179
|
+
"import.viewPages": "See the pages",
|
|
1180
|
+
"import.message": "Import {title} from WordPress",
|
|
1181
|
+
"import.note.leftOut.drafts": "Drafts and pages waiting for review aren't imported: {count}.",
|
|
1182
|
+
"import.note.leftOut.private": "Private pages and posts aren't imported: {count}.",
|
|
1183
|
+
"import.note.leftOut.trash": "Pages and posts in the trash aren't imported: {count}.",
|
|
1184
|
+
"import.note.leftOut.comments": "Comments aren't imported: {count}.",
|
|
1185
|
+
"import.note.leftOut.other": "WordPress content of the kind “{name}” isn't imported: {count}.",
|
|
1186
|
+
"import.note.more": "{items} and {count} more",
|
|
1187
|
+
"import.note.block": "The “{name}” block, from another plugin, was brought across as its text and pictures: {items}.",
|
|
1188
|
+
"import.note.dropped": "The “{name}” block shows something only WordPress has, so it was left out: {items}.",
|
|
1189
|
+
"import.note.shortcode": "The [{name}] shortcode only works in WordPress, so it was left out: {items}.",
|
|
1190
|
+
"import.note.embed": "Something from {name} is now a link to it: {items}.",
|
|
1191
|
+
"import.note.form": "A form was left out. Add a Form block in its place: {items}.",
|
|
1192
|
+
"import.note.script": "A script was left out: {items}.",
|
|
1193
|
+
"import.note.table": "A table became text, since pages have no tables: {items}.",
|
|
1194
|
+
"import.note.posts": "A list of the latest posts was left out of a post: {items}.",
|
|
1195
|
+
"import.note.video": "A video is now a link to it, since posts show videos as links: {items}.",
|
|
1196
|
+
"import.note.oldLinks": "Links that still go to the WordPress site, to pages that weren't imported: {count}, such as {url}.",
|
|
1197
|
+
"import.note.notCopied": "Media files that couldn't be copied, so pages show them from the WordPress site until you upload them: {count}, such as {url}. Most WordPress sites don't let other sites download their files; importing from the command line copies them.",
|
|
1198
|
+
"import.note.unusedMedia": "Files in the media library that no page or post uses, so they weren't copied: {count}.",
|
|
1199
|
+
"import.note.noMenus": "Menus can't be read from a site's address, so the main menu lists the top-level pages. Import an export file to bring the menus across.",
|
|
1200
|
+
"import.error.notExportFile": "That file isn't a WordPress export file. In WordPress, go to Tools → Export, choose All content and download the file.",
|
|
1201
|
+
"import.error.unreachable": "The WordPress site couldn't be read from {address}. Check the address. Some sites don't let other sites read them; then import an export file instead.",
|
|
1202
|
+
"import.error.notWordPress": "{address} doesn't look like a WordPress site, or its API is turned off. Import an export file instead.",
|
|
1203
|
+
"import.error.blocks": "Importing needs blocks this site doesn't have: {blocks}. A developer can add Goodfellow's built-in blocks to goodfellow.config.tsx.",
|
|
1204
|
+
"import.error.other": "The WordPress site couldn't be imported.",
|
|
1205
|
+
"import.names.posts": "Posts",
|
|
1206
|
+
"import.names.post": "Post",
|
|
1207
|
+
"import.names.title": "Title",
|
|
1208
|
+
"import.names.date": "Date",
|
|
1209
|
+
"import.names.summary": "Summary",
|
|
1210
|
+
"import.names.image": "Image",
|
|
1211
|
+
"import.names.categories": "Categories",
|
|
1212
|
+
"import.names.tags": "Tags",
|
|
1213
|
+
"import.names.text": "Text",
|
|
1214
|
+
"import.names.allPosts": "All posts",
|
|
1215
|
+
"export.title": "Export to WordPress",
|
|
1216
|
+
"export.intro": "Download this site as a WordPress export file, to move it to WordPress or keep a copy. Pages keep their addresses and blocks become WordPress's, items of collections with pages become posts, and menus come too. In WordPress, go to Tools → Import → WordPress, choose the file and tick “Download and import file attachments”, which copies the media from this site.",
|
|
1217
|
+
"export.download": "Download the export file",
|
|
1218
|
+
"export.done": "Downloaded the export file.",
|
|
1219
|
+
"export.error": "The export file couldn't be made.",
|
|
1220
|
+
"export.summary.pages": "Pages: {count}",
|
|
1221
|
+
"export.summary.posts": "Posts: {count}",
|
|
1222
|
+
"export.summary.media": "Media files, which WordPress copies from this site: {count}",
|
|
1223
|
+
"export.note.leftOutBlock": "WordPress has nothing like the {name} block, so it was left out: {items}.",
|
|
1224
|
+
"export.note.noPages": "Collections without pages of their own aren't in the file, since WordPress's posts each have a page: {collections}.",
|
|
1225
|
+
"export.note.hidden": "Hidden by their schedule, so they're drafts in the file: {items}.",
|
|
1226
|
+
"export.note.redirects": "Redirects aren't in the file, since WordPress needs a plugin for them: {count}.",
|
|
1227
|
+
"export.note.noAddress": "The site has no address yet, so WordPress can't copy its media. Set it on the General tab, then download the file again.",
|
|
1228
|
+
"export.note.layout": "The header and footer aren't in the file. WordPress's theme shows its own, with the menus.",
|
|
1229
|
+
"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.",
|
|
1230
|
+
"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.",
|
|
1231
|
+
"updates.code": "This site's code is kept apart from its content, at {place}, where Goodfellow's updates are installed.",
|
|
1232
|
+
"updates.code.link": "See it on {host}",
|
|
1233
|
+
"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.",
|
|
1234
|
+
"updates.back.skip": "Don't install Goodfellow {version} again",
|
|
1235
|
+
"updates.local": "Updates are shown in the published site's admin panel, where you sign in with GitHub or GitLab.",
|
|
1236
|
+
"updates.demo": "Updates aren't installed in a demo.",
|
|
1237
|
+
"updates.loading": "Checking for updates…",
|
|
1238
|
+
"updates.current": "This site uses Goodfellow {version}.",
|
|
1239
|
+
"updates.unknownVersion": "This site's Goodfellow release couldn't be found.",
|
|
1240
|
+
"updates.missing": "This site's build setup doesn't install updates on its own, for example because it was made before it could, or because it's published with Vercel. A developer can add it, as the documentation explains.",
|
|
1241
|
+
"updates.needsSetup": "Automatic updates aren't turned on yet. They need {host} to let the site's build publish the update, and to run it each night.",
|
|
1242
|
+
"updates.enable": "Turn on automatic updates",
|
|
1243
|
+
"updates.fixes.title": "Fixes",
|
|
1244
|
+
"updates.fixes.ready": "Fixes up to Goodfellow {version} are ready. They install on their own tonight, or you can install them now.",
|
|
1245
|
+
"updates.fixes.readyPaused": "Fixes up to Goodfellow {version} are ready. Automatic fixes are turned off, so they're only installed if you install them now.",
|
|
1246
|
+
"updates.fixes.now": "Install fixes now",
|
|
1247
|
+
"updates.fixes.none": "The site has every fix for its release.",
|
|
1248
|
+
"updates.newer.title": "Goodfellow {version} is out",
|
|
1249
|
+
"updates.newer.body": "Newer releases can change how some things work, so they're only installed when one of the site's owners chooses to. Read what's new first.",
|
|
1250
|
+
"updates.newer.whatsNew": "What's new",
|
|
1251
|
+
"updates.newer.install": "Update to {version}",
|
|
1252
|
+
"updates.newer.onlyOwners": "Only the site's owners can install a newer release.",
|
|
1253
|
+
"updates.automatic.label": "Install fixes automatically each night",
|
|
1254
|
+
"updates.automatic.hint": "Turn this off to keep the site on the release it has now, for example while something an update changed is looked into. Fixes are then only installed when someone installs them here.",
|
|
1255
|
+
"updates.automatic.onlyOwners": "Only the site's owners can change this.",
|
|
1256
|
+
"updates.automatic.turnOff": "Turn off automatic fixes",
|
|
1257
|
+
"updates.automatic.turnOn": "Turn on automatic fixes",
|
|
1258
|
+
"updates.back.title": "Something stopped working?",
|
|
1259
|
+
"updates.back.body": "If something stopped working when Goodfellow {current} was installed, you can go back to {version}. The site is checked to still build with it first. Updates won't install {current} again, but will install fixes that come after it.",
|
|
1260
|
+
"updates.back.button": "Go back to {version}",
|
|
1261
|
+
"updates.back.confirm": "Go back to Goodfellow {version}? It takes a few minutes.",
|
|
1262
|
+
"updates.skipped.title": "Releases that won't be installed",
|
|
1263
|
+
"updates.skipped.body": "The site went back from these releases, so updates don't install them.",
|
|
1264
|
+
"updates.skipped.allow": "Allow {version} again",
|
|
1265
|
+
"updates.skipped.allowMessage": "Allow Goodfellow {version} again",
|
|
1266
|
+
"updates.nightly.title": "Rebuilding overnight",
|
|
1267
|
+
"updates.nightly.body": "Each night, the site can check whether it needs rebuilding: for fixes to install, for something it shows by today's date, such as an event's next date, or for a change that didn't go live.",
|
|
1268
|
+
"updates.nightly.changed": "Only when something changed (recommended)",
|
|
1269
|
+
"updates.nightly.always": "Every night",
|
|
1270
|
+
"updates.nightly.off": "Never",
|
|
1271
|
+
"updates.nightly.offWarning": "Without rebuilding overnight, what the site shows by today's date, such as lists of what's coming up, goes out of date, and fixes aren't installed on their own. The site is still rebuilt each time someone publishes.",
|
|
1272
|
+
"updates.nightly.unknown": "Sign in as an owner to see or change this.",
|
|
1273
|
+
"updates.nightly.onlyOwners": "Only the site's owners can change this.",
|
|
1274
|
+
"updates.nightly.old": "This site's build setup rebuilds it every night. A newer setup from Goodfellow's starter rebuilds it only when something changed.",
|
|
1275
|
+
"updates.rebuild.title": "Rebuild the site",
|
|
1276
|
+
"updates.rebuild.body": "Rebuilds the site now without publishing anything, so the live site catches up, such as with another calendar's events or after a rebuild that didn't work.",
|
|
1277
|
+
"updates.rebuild.button": "Rebuild now",
|
|
1278
|
+
"updates.rebuild.started": "The site is rebuilding. It takes a few minutes.",
|
|
1279
|
+
"updates.started": "The update has started. It takes a few minutes, and this page shows how it went.",
|
|
1280
|
+
"updates.last.title": "Last update",
|
|
1281
|
+
"updates.last.none": "No update has run yet.",
|
|
1282
|
+
"updates.last.running": "An update is running now. It takes a few minutes.",
|
|
1283
|
+
"updates.last.updated": "On {date}, Goodfellow {version} was installed.",
|
|
1284
|
+
"updates.last.updatedSome": "On {date}, an update was installed.",
|
|
1285
|
+
"updates.last.wentBack": "On {date}, the site went back to Goodfellow {version}.",
|
|
1286
|
+
"updates.last.paused": "On {date}, automatic fixes were turned off, so nothing was installed.",
|
|
1287
|
+
"updates.last.upToDate": "On {date}, there was nothing new to install.",
|
|
1288
|
+
"updates.last.failed": "On {date}, the update didn't work, so nothing changed and the site stayed as it was.",
|
|
1289
|
+
"updates.last.unknown": "An update ran on {date}.",
|
|
1290
|
+
"updates.last.details": "See the details on {host}",
|
|
1291
|
+
"updates.failed.build": "The site didn't build with Goodfellow {version}; a developer may need to look at the details.",
|
|
1292
|
+
"updates.failed.install": "Its packages couldn't be installed. The next night tries again.",
|
|
1293
|
+
"updates.failed.blocks": "One of the site's blocks couldn't be updated.",
|
|
1294
|
+
"updates.failed.publish": "Someone published at the same time. The next night tries again.",
|
|
1295
|
+
"updates.kept": "Someone changed these files of the site's blocks, so the update left them as they were. A developer can compare them with the new versions:",
|
|
1296
|
+
"updates.error.load": "The updates couldn't be checked. Check your connection and try again.",
|
|
1297
|
+
"updates.error.not-allowed": "Your account can't do that on {host}. Ask one of the site's owners.",
|
|
1298
|
+
"updates.error.missing": "This site's build setup doesn't install updates, so there's nothing to run.",
|
|
1299
|
+
"updates.error.save": "The change couldn't be saved. Try again in a moment.",
|
|
1300
|
+
"updates.error.other": "Something went wrong. Try again in a moment.",
|
|
1301
|
+
"editors.intro": "The people who can edit this site.",
|
|
1302
|
+
"editors.roles": "Editors can change and publish everything in the admin panel. Owners can also invite and remove editors, and connect a domain.",
|
|
1303
|
+
"editors.local": "Editors are invited in the published site's admin panel, where you sign in with GitHub or GitLab.",
|
|
1304
|
+
"editors.demo": "Editors can't be invited in a demo.",
|
|
1305
|
+
"editors.loading": "Loading editors…",
|
|
1306
|
+
"editors.onlyOwners": "Only the site's owners can invite and remove editors.",
|
|
1307
|
+
"editors.cantPublish": "Editors can't publish yet: {host} only lets owners publish to the site until it's told to let editors publish too.",
|
|
1308
|
+
"editors.letPublish": "Let editors publish",
|
|
1309
|
+
"editors.person": "Person",
|
|
1310
|
+
"editors.role": "Can",
|
|
1311
|
+
"editors.roleFor": "What {name} can do",
|
|
1312
|
+
"editors.actions": "Actions",
|
|
1313
|
+
"editors.role.owner": "Owner",
|
|
1314
|
+
"editors.role.editor": "Editor",
|
|
1315
|
+
"editors.role.viewer": "Can't publish",
|
|
1316
|
+
"editors.you": "You",
|
|
1317
|
+
"editors.invited": "Invited, hasn't accepted yet",
|
|
1318
|
+
"editors.inherited": "Through a group on {host}",
|
|
1319
|
+
"editors.remove": "Remove",
|
|
1320
|
+
"editors.withdraw": "Withdraw invitation",
|
|
1321
|
+
"editors.removeTitle": "Remove {name}?",
|
|
1322
|
+
"editors.removeBody": "They won't be able to edit the site any more. Their account on {host} stays as it is.",
|
|
1323
|
+
"editors.withdrawTitle": "Withdraw the invitation to {name}?",
|
|
1324
|
+
"editors.withdrawBody": "The invitation stops working. You can invite them again later.",
|
|
1325
|
+
"editors.invite.title": "Invite someone",
|
|
1326
|
+
"editors.invite.field": "Their {host} username",
|
|
1327
|
+
"editors.invite.hint": "They need a {host} account. {host} emails them an invitation, which they accept before they can sign in here.",
|
|
1328
|
+
"editors.invite.fieldEmail": "Their email address or {host} username",
|
|
1329
|
+
"editors.invite.hintEmail": "With an email address, {host} emails them an invitation, and they make an account if they don't have one.",
|
|
1330
|
+
"editors.invite.submit": "Invite",
|
|
1331
|
+
"editors.error.load": "The editors couldn't be loaded. Check your connection and try again.",
|
|
1332
|
+
"editors.error.not-allowed": "You can't change who edits the site.",
|
|
1333
|
+
"editors.error.not-found": "There's no {host} account with that name. Check the spelling.",
|
|
1334
|
+
"editors.error.invalid": "That isn't a username or email address that {host} accepts.",
|
|
1335
|
+
"editors.error.already": "They already edit the site, or have been invited.",
|
|
1336
|
+
"editors.error.other": "Something went wrong. Try again in a moment.",
|
|
1337
|
+
"general.siteTitle": "Site name",
|
|
1338
|
+
"general.description": "Description",
|
|
1339
|
+
"general.descriptionHint": "Shown by search engines when a page doesn't have its own description.",
|
|
1340
|
+
"general.url": "Site address",
|
|
1341
|
+
"general.urlHint": "The full public address, such as https://example.org. Used for search engines and link previews.",
|
|
1342
|
+
"general.externalLinksInNewTab": "Open links to other sites in a new tab",
|
|
1343
|
+
"general.externalLinksInNewTabHint": "Applies to every link to another site: menus, buttons and text. Links to this site's own pages open in the same tab.",
|
|
1344
|
+
"general.language": "Language code",
|
|
1345
|
+
"general.languageHint": "Such as en, es or fr.",
|
|
1346
|
+
"languages.intro": "A site can be in several languages. Pages in the main language are at the site's address; each other language's pages are under its code, such as /es/about for Spanish. Translate a page from the Pages screen once its language is published.",
|
|
1347
|
+
"languages.main": "Main language",
|
|
1348
|
+
"languages.mainCodeHintUsed": "It can't change while the site has pages or items in other languages.",
|
|
1349
|
+
"languages.name": "Name",
|
|
1350
|
+
"languages.nameHint": "How the language switch names it, in that language.",
|
|
1351
|
+
"languages.new": "New language",
|
|
1352
|
+
"languages.add": "Add a language",
|
|
1353
|
+
"languages.remove": "Remove this language",
|
|
1354
|
+
"languages.codeHint": "Such as es, fr or pt-BR. Its pages go under its code, so it can't change once published.",
|
|
1355
|
+
"languages.codeHintPublished": "Its pages are under {prefix}.",
|
|
1356
|
+
"languages.codeInvalid": "Use a language code, such as es, fr or pt-BR.",
|
|
1357
|
+
"languages.codeTaken": "The site has this language already.",
|
|
1358
|
+
"languages.codeUsed": "The site has pages under {prefix} already. Move them first, or choose another code.",
|
|
1359
|
+
"languages.titleHint": "The site's title in this language. Left empty, it's the main language's.",
|
|
1360
|
+
"languages.inUse": "Pages and items in this language: {count}. To remove the language, delete them or move them out of it first.",
|
|
1361
|
+
"general.timeZone": "Time zone",
|
|
1362
|
+
"general.timeZoneHint": "The time zone events' times are in. Calendars show them in it, and visitors' calendars move them to their own.",
|
|
1363
|
+
"general.timeZoneNone": "Not set: times are shown as they're written",
|
|
1364
|
+
"general.timeZoneUseMine": "Use {zone}",
|
|
1365
|
+
"general.titleTemplate": "Browser tab title",
|
|
1366
|
+
"general.titleTemplateHint": "%s is replaced by the page title, so \"%s | My site\" shows \"About | My site\".",
|
|
1367
|
+
"general.favicon": "Browser tab icon",
|
|
1368
|
+
"general.logo": "Logo",
|
|
1369
|
+
"general.logoAlt": "Logo description",
|
|
1370
|
+
"general.logoDark": "Logo in dark mode",
|
|
1371
|
+
"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.",
|
|
1372
|
+
"general.socialImage": "Image when shared on social media",
|
|
1373
|
+
"general.contact": "Contact details",
|
|
1374
|
+
"general.contactHint": "Shown wherever a Contact details block is placed, such as the footer.",
|
|
1375
|
+
"general.address": "Address",
|
|
1376
|
+
"general.phone": "Phone",
|
|
1377
|
+
"general.email": "Email",
|
|
1378
|
+
"forms.title": "Forms",
|
|
1379
|
+
"forms.hint": "Where answers to the site's forms go. The form service emails them to you: they're never kept with the site's pages.",
|
|
1380
|
+
"forms.service": "Form service",
|
|
1381
|
+
"forms.service.web3forms": "Web3Forms: free for 250 answers a month, with no account",
|
|
1382
|
+
"forms.service.formspree": "Formspree",
|
|
1383
|
+
"forms.service.other": "Another service",
|
|
1384
|
+
"forms.accessKey": "Web3Forms access key",
|
|
1385
|
+
"forms.accessKeyHint": "On Web3Forms' site, enter the email address answers should go to. Web3Forms emails you a key: paste it here.",
|
|
1386
|
+
"forms.accessKeyLink": "Get an access key from Web3Forms",
|
|
1387
|
+
"forms.captcha": "Ask visitors to show they aren't robots (hCaptcha)",
|
|
1388
|
+
"forms.captchaHint": "Adds Web3Forms' hCaptcha and its script to pages with a form. To turn away answers without it, also turn hCaptcha on for the form in a Web3Forms account.",
|
|
1389
|
+
"forms.formspree": "Formspree form address",
|
|
1390
|
+
"forms.formspreeHint": "Make a form in Formspree and copy its address, such as https://formspree.io/f/xyzabcde. On Formspree's free plan, visitors see Formspree's own thank-you page.",
|
|
1391
|
+
"forms.address": "Address forms send answers to",
|
|
1392
|
+
"forms.addressHint": "The service's address for forms, starting with https://. Forms send the thank-you page's address as _next, the subject as _subject, and a hidden _gotcha field that only robots fill in, as Formspree, FormSubmit and Formcarry expect.",
|
|
1393
|
+
"forms.error.key": "This isn't a Web3Forms access key. Copy it from Web3Forms' email: it looks like 0b5c4f7e-1a2b-4c3d-8e9f-0123456789ab.",
|
|
1394
|
+
"forms.error.formspree": "This isn't a Formspree form's address. It looks like https://formspree.io/f/xyzabcde.",
|
|
1395
|
+
"forms.error.address": "The address must be a full address starting with https://.",
|
|
1396
|
+
"theme.colors": "Colors",
|
|
1397
|
+
"theme.fonts": "Fonts",
|
|
1398
|
+
"theme.fontsHint": "Choose from Google Fonts. Site default uses each visitor's standard font.",
|
|
1399
|
+
"font.default": "Site default",
|
|
1400
|
+
"font.defaultHint": "Visitor's standard font",
|
|
1401
|
+
"font.search": "Search fonts",
|
|
1402
|
+
"font.searchHint": "Type to search Google Fonts, then use the arrow keys and Enter to choose.",
|
|
1403
|
+
"font.loading": "Loading fonts…",
|
|
1404
|
+
"font.noMatch": "No fonts match “{query}”.",
|
|
1405
|
+
"font.category.sans-serif": "Sans serif",
|
|
1406
|
+
"font.category.serif": "Serif",
|
|
1407
|
+
"font.category.display": "Display",
|
|
1408
|
+
"font.category.handwriting": "Handwriting",
|
|
1409
|
+
"font.category.monospace": "Monospace",
|
|
1410
|
+
"theme.headingFont": "Headings",
|
|
1411
|
+
"theme.bodyFont": "Text",
|
|
1412
|
+
"theme.radius": "Corner rounding",
|
|
1413
|
+
"theme.radiusHint": "Such as 0.5rem, or 0 for square corners.",
|
|
1414
|
+
"theme.color.background": "Background",
|
|
1415
|
+
"theme.color.foreground": "Text",
|
|
1416
|
+
"theme.color.primary": "Main color",
|
|
1417
|
+
"theme.color.primaryForeground": "Text on main color",
|
|
1418
|
+
"theme.color.secondary": "Second color",
|
|
1419
|
+
"theme.color.secondaryForeground": "Text on second color",
|
|
1420
|
+
"theme.color.muted": "Subtle background",
|
|
1421
|
+
"theme.color.mutedForeground": "Subtle text",
|
|
1422
|
+
"theme.color.accent": "Highlight",
|
|
1423
|
+
"theme.color.accentForeground": "Text on highlight",
|
|
1424
|
+
"theme.color.border": "Borders",
|
|
1425
|
+
"theme.default": "Default",
|
|
1426
|
+
"theme.darkMode": "Dark mode",
|
|
1427
|
+
"theme.darkModeOn": "Follow each visitor's light or dark setting",
|
|
1428
|
+
"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.",
|
|
1429
|
+
"theme.darkColors": "Colors in dark mode",
|
|
1430
|
+
"theme.preview": "Preview",
|
|
1431
|
+
"theme.previewLight": "Light",
|
|
1432
|
+
"theme.previewDark": "Dark",
|
|
1433
|
+
"theme.pickColor": "Choose a color for {name}",
|
|
1434
|
+
"theme.code": "Code colors",
|
|
1435
|
+
"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.",
|
|
1436
|
+
"theme.codeLight": "Light colors",
|
|
1437
|
+
"theme.codeDark": "Dark colors",
|
|
1438
|
+
"theme.codeBackground": "Show code on the subtle background color",
|
|
1439
|
+
"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.",
|
|
1440
|
+
"menus.intro": "Menus are lists of links. Show one anywhere with the Menu block, using its name.",
|
|
1441
|
+
"menus.empty": "There are no menus yet.",
|
|
1442
|
+
"menus.add": "Add menu",
|
|
1443
|
+
"menus.newName": "New menu name",
|
|
1444
|
+
"menus.nameInvalid": "Use lowercase letters, numbers and hyphens, and a name no other menu uses.",
|
|
1445
|
+
"menus.deleteMenu": "Delete menu",
|
|
1446
|
+
"menus.addLink": "Add link",
|
|
1447
|
+
"menus.addSublink": "Add link below this one",
|
|
1448
|
+
"menus.label": "Label",
|
|
1449
|
+
"menus.href": "Link to",
|
|
1450
|
+
"menus.moveUp": "Move up",
|
|
1451
|
+
"menus.moveDown": "Move down",
|
|
1452
|
+
"menus.remove": "Remove",
|
|
1453
|
+
"translations.add": "Translate into {language}",
|
|
1454
|
+
"translations.current": "Up to date",
|
|
1455
|
+
"translations.outdated": "Out of date",
|
|
1456
|
+
"translations.optionOutdated": "{language} (out of date)",
|
|
1457
|
+
"translations.optionAdd": "Translate into {language}…",
|
|
1458
|
+
"translations.markCurrent": "Mark as up to date",
|
|
1459
|
+
"translations.markCurrentMessage": "Mark {title} as up to date",
|
|
1460
|
+
"translations.of": "Translation of {title}",
|
|
1461
|
+
"translations.entryOf": "In {language}: translation of {title}",
|
|
1462
|
+
"translations.inLanguage": "In {language}",
|
|
1463
|
+
"translations.addTitle": "Translate {title} into {language}",
|
|
1464
|
+
"translations.addHint": "This makes a copy in {language} for you to translate. The Pages screen says when the original changes, so you know to update the translation.",
|
|
1465
|
+
"translations.addSubmit": "Make the copy",
|
|
1466
|
+
"translations.addMessage": "Translate {title} into {language}",
|
|
1467
|
+
"translations.addressLanguage": "Pages in {language} have addresses starting with {prefix}.",
|
|
1468
|
+
"menus.language": "Language",
|
|
1469
|
+
"layout.languageShared": "Pages in {language} show the main language's {part}, with {language}'s menus and site name. Give {language} a {part} of its own to translate the words typed into it.",
|
|
1470
|
+
"layout.languageCopy": "Make a {part} for {language}",
|
|
1471
|
+
"layout.languageCopyMessage": "Make a {part} for {language}",
|
|
1472
|
+
"layout.languageRemove": "Use the main language's {part}",
|
|
1473
|
+
"layout.languageRemoveMessage": "Use the main language's {part} in {language}",
|
|
1474
|
+
"layout.languageMessage": "Update the {part} in {language}",
|
|
1475
|
+
"newEntry.languageHint": "For an item only in this language. To translate an item, use “Translate into” in the list instead, which keeps the two linked.",
|
|
1476
|
+
"menus.ownMenus": "{language} has menus of its own",
|
|
1477
|
+
"menus.sameAsMain": "Pages in {language} show the main language's menus. Give it menus of its own to translate their links' names and point them at its pages.",
|
|
1478
|
+
"menus.newLink": "New link",
|
|
1479
|
+
"redirects.intro": "Send visitors from an old address to a new one, such as when a page has moved or this site replaced an older one. Search engines move the old address's place in their results to the new one.",
|
|
1480
|
+
"redirects.empty": "There are no redirects yet.",
|
|
1481
|
+
"redirects.from": "Old address",
|
|
1482
|
+
"redirects.to": "Send visitors to",
|
|
1483
|
+
"redirects.add": "Add redirect",
|
|
1484
|
+
"redirects.remove": "Remove",
|
|
1485
|
+
"redirects.message": "Message while visitors are sent on",
|
|
1486
|
+
"redirects.messageHint": "Shown at an old address for a moment, or for good in browsers without JavaScript. {placeholder} becomes a link to where visitors are going. Leave it empty for the message shown here.",
|
|
1487
|
+
"redirects.unused": "A page has this address, so visitors see the page instead. Remove the redirect, or move the page.",
|
|
1488
|
+
"redirects.problem.noFrom": "Add the old address, such as /about-us.",
|
|
1489
|
+
"redirects.problem.badFrom": "This isn't an address on this site. Start with /, such as /about-us, or paste the old page's full address.",
|
|
1490
|
+
"redirects.problem.query": "Addresses with a ? in them can't be redirected, since the site can't tell them apart from the address before the ?.",
|
|
1491
|
+
"redirects.problem.home": "The home page can't be redirected.",
|
|
1492
|
+
"redirects.problem.reserved": "This address is used by the site itself, so it can't be redirected.",
|
|
1493
|
+
"redirects.problem.noTo": "Add where to send visitors, such as /about.",
|
|
1494
|
+
"redirects.problem.badTo": "Send visitors to a page on this site, starting with /, or to another website's address, starting with https://.",
|
|
1495
|
+
"redirects.problem.loop": "This sends visitors back to the same address.",
|
|
1496
|
+
"redirects.problem.duplicate": "Another redirect is from the same address. Remove one of them.",
|
|
1497
|
+
"css.intro": "CSS here applies to every page and overrides the blocks' own styles. Tailwind's @apply works too.",
|
|
1498
|
+
"css.label": "Custom CSS",
|
|
1499
|
+
"code.editorHint": "Tab indents, and suggestions appear as you type. Press Escape, then Tab, to leave the editor. Ctrl+F finds and replaces.",
|
|
1500
|
+
"code.intro": "Code from services such as analytics or site verification, added to every page of the site. It never runs in the admin panel, so check it on the site once it's published. Paste only code you trust: it can change anything on the site.",
|
|
1501
|
+
"code.head": "In the page head",
|
|
1502
|
+
"code.headHint": "Script, style, link, meta and noscript tags, such as the ones Google Analytics gives you.",
|
|
1503
|
+
"code.body": "At the end of the page",
|
|
1504
|
+
"code.bodyHint": "Any HTML, such as a chat widget, added just before the end of every page.",
|
|
1505
|
+
"code.error.text": "The code in the page head has text outside a tag: \"{text}\". Only tags such as <script> and <meta> can go there.",
|
|
1506
|
+
"code.error.tag": "A <{tag}> tag can't go in the page head. Put it in \"At the end of the page\" instead.",
|
|
1507
|
+
"code.error.unclosed": "A {tag} tag in the page head isn't closed."
|
|
1508
|
+
};
|
|
1509
|
+
const StringsContext = createContext(defaultStrings);
|
|
1510
|
+
function StringsProvider({ strings, children }) {
|
|
1511
|
+
const value = useMemo(() => ({
|
|
1512
|
+
...defaultStrings,
|
|
1513
|
+
...strings
|
|
1514
|
+
}), [strings]);
|
|
1515
|
+
return /* @__PURE__ */ jsx(StringsContext.Provider, {
|
|
1516
|
+
value,
|
|
1517
|
+
children
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
/** The whole table, with any strings the admin panel was given, for frames, which show some of the admin panel's text. */
|
|
1521
|
+
function useStringTable() {
|
|
1522
|
+
return useContext(StringsContext);
|
|
1523
|
+
}
|
|
1524
|
+
/** Returns `t(key, values)`, which looks up a string and fills in its `{placeholders}`. */
|
|
1525
|
+
function useStrings() {
|
|
1526
|
+
const strings = useContext(StringsContext);
|
|
1527
|
+
return (key, values) => strings[key].replace(/\{(\w+)\}/g, (match, name) => values?.[name] ?? match);
|
|
1528
|
+
}
|
|
1529
|
+
//#endregion
|
|
1530
|
+
//#region src/ui.tsx
|
|
1531
|
+
function Button({ variant = "secondary", type = "button", ...props }) {
|
|
1532
|
+
return /* @__PURE__ */ jsx("button", {
|
|
1533
|
+
type,
|
|
1534
|
+
...props,
|
|
1535
|
+
className: `gfa-button gfa-button-${variant} ${props.className ?? ""}`
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
/** A labelled form control with an optional hint and error message. */
|
|
1539
|
+
function Field({ label, hint, error, children }) {
|
|
1540
|
+
const id = useId();
|
|
1541
|
+
const describedBy = [hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(" ") || void 0;
|
|
1542
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1543
|
+
className: "gfa-field",
|
|
1544
|
+
children: [
|
|
1545
|
+
/* @__PURE__ */ jsx("label", {
|
|
1546
|
+
htmlFor: id,
|
|
1547
|
+
className: "gfa-label",
|
|
1548
|
+
children: label
|
|
1549
|
+
}),
|
|
1550
|
+
children({
|
|
1551
|
+
id,
|
|
1552
|
+
"aria-describedby": describedBy,
|
|
1553
|
+
"aria-invalid": error ? true : void 0
|
|
1554
|
+
}),
|
|
1555
|
+
hint && /* @__PURE__ */ jsx("p", {
|
|
1556
|
+
id: `${id}-hint`,
|
|
1557
|
+
className: "gfa-hint",
|
|
1558
|
+
children: hint
|
|
1559
|
+
}),
|
|
1560
|
+
error && /* @__PURE__ */ jsx("p", {
|
|
1561
|
+
id: `${id}-error`,
|
|
1562
|
+
className: "gfa-error-text",
|
|
1563
|
+
children: error
|
|
1564
|
+
})
|
|
1565
|
+
]
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
function TextField({ label, hint, error, value, onChange, ...input }) {
|
|
1569
|
+
return /* @__PURE__ */ jsx(Field, {
|
|
1570
|
+
label,
|
|
1571
|
+
hint,
|
|
1572
|
+
error,
|
|
1573
|
+
children: (props) => /* @__PURE__ */ jsx("input", {
|
|
1574
|
+
...input,
|
|
1575
|
+
...props,
|
|
1576
|
+
className: "gfa-input",
|
|
1577
|
+
value,
|
|
1578
|
+
onChange: (event) => onChange(event.target.value)
|
|
1579
|
+
})
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
/** Shows what went wrong in plain words, with the technical details behind a toggle. */
|
|
1583
|
+
function ErrorMessage({ message, error, action }) {
|
|
1584
|
+
const t = useStrings();
|
|
1585
|
+
const details = error instanceof Error ? `${error.name}: ${error.message}` : error ? String(error) : "";
|
|
1586
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1587
|
+
className: "gfa-notice gfa-notice-error",
|
|
1588
|
+
role: "alert",
|
|
1589
|
+
children: [
|
|
1590
|
+
/* @__PURE__ */ jsx("p", { children: message }),
|
|
1591
|
+
details && /* @__PURE__ */ jsxs("details", {
|
|
1592
|
+
className: "gfa-details",
|
|
1593
|
+
children: [/* @__PURE__ */ jsx("summary", { children: t("details.show") }), /* @__PURE__ */ jsx("pre", { children: details })]
|
|
1594
|
+
}),
|
|
1595
|
+
action
|
|
1596
|
+
]
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
/** A modal dialog using the native `<dialog>` element. */
|
|
1600
|
+
function Dialog({ title, onClose, children }) {
|
|
1601
|
+
const ref = useRef(null);
|
|
1602
|
+
const titleId = useId();
|
|
1603
|
+
useEffect(() => {
|
|
1604
|
+
ref.current?.showModal();
|
|
1605
|
+
}, []);
|
|
1606
|
+
return /* @__PURE__ */ jsxs("dialog", {
|
|
1607
|
+
ref,
|
|
1608
|
+
className: "gfa-dialog",
|
|
1609
|
+
onClose,
|
|
1610
|
+
"aria-labelledby": titleId,
|
|
1611
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
1612
|
+
id: titleId,
|
|
1613
|
+
className: "gfa-dialog-title",
|
|
1614
|
+
children: title
|
|
1615
|
+
}), children]
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
/** How long "Published." shows under the Publish button. */
|
|
1619
|
+
const PUBLISHED_NOTICE_MS = 5e3;
|
|
1620
|
+
/**
|
|
1621
|
+
* "Published.", just under the Publish button, which goes after a few seconds.
|
|
1622
|
+
* Put it in the `gfa-header-actions` the button is in; screens hide it sooner
|
|
1623
|
+
* when anything changes. `onHide` must keep its identity, or the time restarts.
|
|
1624
|
+
* It's placed on the screen, under its parent's right-hand edge, since editors'
|
|
1625
|
+
* headers clip what hangs below them.
|
|
1626
|
+
*/
|
|
1627
|
+
function PublishedNotice({ onHide }) {
|
|
1628
|
+
const t = useStrings();
|
|
1629
|
+
const ref = useRef(null);
|
|
1630
|
+
const [place, setPlace] = useState();
|
|
1631
|
+
useLayoutEffect(() => {
|
|
1632
|
+
const anchor = ref.current?.parentElement?.getBoundingClientRect();
|
|
1633
|
+
if (anchor) setPlace({
|
|
1634
|
+
top: anchor.bottom + 8,
|
|
1635
|
+
right: document.documentElement.clientWidth - anchor.right
|
|
1636
|
+
});
|
|
1637
|
+
}, []);
|
|
1638
|
+
useEffect(() => {
|
|
1639
|
+
const timer = window.setTimeout(onHide, PUBLISHED_NOTICE_MS);
|
|
1640
|
+
return () => window.clearTimeout(timer);
|
|
1641
|
+
}, [onHide]);
|
|
1642
|
+
return /* @__PURE__ */ jsx("p", {
|
|
1643
|
+
ref,
|
|
1644
|
+
className: "gfa-published",
|
|
1645
|
+
role: "status",
|
|
1646
|
+
style: place ?? { visibility: "hidden" },
|
|
1647
|
+
children: t("publish.done")
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
/** `value`, once it's stayed the same for `ms`: for work too slow to do on every keystroke, such as previews. */
|
|
1651
|
+
function useDebounced(value, ms) {
|
|
1652
|
+
const [settled, setSettled] = useState(value);
|
|
1653
|
+
useEffect(() => {
|
|
1654
|
+
const timer = setTimeout(() => setSettled(value), ms);
|
|
1655
|
+
return () => clearTimeout(timer);
|
|
1656
|
+
}, [value, ms]);
|
|
1657
|
+
return settled;
|
|
1658
|
+
}
|
|
1659
|
+
//#endregion
|
|
1660
|
+
//#region src/checks-dialog.tsx
|
|
1661
|
+
function ProblemList({ content, problems }) {
|
|
1662
|
+
const t = useStrings();
|
|
1663
|
+
return /* @__PURE__ */ jsx("ul", {
|
|
1664
|
+
className: "gfa-check-list",
|
|
1665
|
+
children: problems.map((problem) => /* @__PURE__ */ jsx("li", { children: describeProblem(t, content, problem) }, JSON.stringify(problem)))
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* What the site checks found in a publish: errors it would bring stop it,
|
|
1670
|
+
* and the rest, which the editor can publish anyway, are listed to fix first.
|
|
1671
|
+
*/
|
|
1672
|
+
function ChecksDialog({ checks, content, onDecide }) {
|
|
1673
|
+
const t = useStrings();
|
|
1674
|
+
const blocked = checks.blocking.length > 0;
|
|
1675
|
+
return /* @__PURE__ */ jsxs(Dialog, {
|
|
1676
|
+
title: t(blocked ? "checks.blocked.title" : "checks.warnings.title"),
|
|
1677
|
+
onClose: () => onDecide(false),
|
|
1678
|
+
children: [
|
|
1679
|
+
/* @__PURE__ */ jsx("p", { children: t(blocked ? "checks.blocked.intro" : "checks.warnings.intro") }),
|
|
1680
|
+
/* @__PURE__ */ jsx(ProblemList, {
|
|
1681
|
+
content,
|
|
1682
|
+
problems: blocked ? checks.blocking : checks.warnings
|
|
1683
|
+
}),
|
|
1684
|
+
blocked && checks.warnings.length > 0 && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", { children: t("checks.alsoWarnings") }), /* @__PURE__ */ jsx(ProblemList, {
|
|
1685
|
+
content,
|
|
1686
|
+
problems: checks.warnings
|
|
1687
|
+
})] }),
|
|
1688
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1689
|
+
className: "gfa-dialog-actions",
|
|
1690
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
1691
|
+
onClick: () => onDecide(false),
|
|
1692
|
+
children: t("checks.goBack")
|
|
1693
|
+
}), !blocked && /* @__PURE__ */ jsx(Button, {
|
|
1694
|
+
variant: "primary",
|
|
1695
|
+
onClick: () => onDecide(true),
|
|
1696
|
+
children: t("checks.publishAnyway")
|
|
1697
|
+
})]
|
|
1698
|
+
})
|
|
1699
|
+
]
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
//#endregion
|
|
1703
|
+
//#region src/drafts.ts
|
|
1704
|
+
const DATABASE = "goodfellow-drafts";
|
|
1705
|
+
const TABLE = "drafts";
|
|
1706
|
+
const CURRENT_KEY = "goodfellow.draft";
|
|
1707
|
+
function request(pending) {
|
|
1708
|
+
return new Promise((resolve, reject) => {
|
|
1709
|
+
pending.onsuccess = () => resolve(pending.result);
|
|
1710
|
+
pending.onerror = () => reject(pending.error);
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Keeps the draft of a site whose backend has no drafts of its own (a
|
|
1715
|
+
* full-stack site's server, a demo, the development servers) in the browser's
|
|
1716
|
+
* IndexedDB, which can hold uploads too large for local storage, so it stays
|
|
1717
|
+
* when the tab closes. `key` tells apart sites served from the same address.
|
|
1718
|
+
* Where the browser keeps nothing, such as in some private windows, it's kept
|
|
1719
|
+
* only while the page is open.
|
|
1720
|
+
*/
|
|
1721
|
+
function browserDraftKeeper(key) {
|
|
1722
|
+
if (typeof indexedDB === "undefined") return memoryKeeper();
|
|
1723
|
+
let database;
|
|
1724
|
+
const open = () => {
|
|
1725
|
+
database ??= new Promise((resolve, reject) => {
|
|
1726
|
+
const opening = indexedDB.open(DATABASE, 1);
|
|
1727
|
+
opening.onupgradeneeded = () => opening.result.createObjectStore(TABLE);
|
|
1728
|
+
opening.onsuccess = () => resolve(opening.result);
|
|
1729
|
+
opening.onerror = () => reject(opening.error);
|
|
1730
|
+
});
|
|
1731
|
+
return database;
|
|
1732
|
+
};
|
|
1733
|
+
const table = async (mode) => (await open()).transaction(TABLE, mode).objectStore(TABLE);
|
|
1734
|
+
return {
|
|
1735
|
+
load: async () => await request((await table("readonly")).get(key)),
|
|
1736
|
+
save: async (draft) => {
|
|
1737
|
+
const store = await table("readwrite");
|
|
1738
|
+
if (draft) await request(store.put(draft, key));
|
|
1739
|
+
else await request(store.delete(key));
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
/** The draft this browser was working in on a site, so it opens again when the admin panel does. */
|
|
1744
|
+
function rememberedDraft(site) {
|
|
1745
|
+
try {
|
|
1746
|
+
return localStorage.getItem(`${CURRENT_KEY}:${site}`) ?? void 0;
|
|
1747
|
+
} catch {
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
function rememberDraft(site, name) {
|
|
1752
|
+
try {
|
|
1753
|
+
if (name) localStorage.setItem(`${CURRENT_KEY}:${site}`, name);
|
|
1754
|
+
else localStorage.removeItem(`${CURRENT_KEY}:${site}`);
|
|
1755
|
+
} catch {}
|
|
1756
|
+
}
|
|
1757
|
+
/** When a draft started, from its name (`newDraftName()`), as the computer writes dates, or its name where that doesn't say. */
|
|
1758
|
+
function draftStarted(name) {
|
|
1759
|
+
const match = /^(\d{4})(\d{2})(\d{2})-/.exec(name);
|
|
1760
|
+
if (!match) return name;
|
|
1761
|
+
const [, year, month, day] = match;
|
|
1762
|
+
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
1763
|
+
return Number.isNaN(date.getTime()) ? name : date.toLocaleDateString(void 0, {
|
|
1764
|
+
dateStyle: "long",
|
|
1765
|
+
timeZone: "UTC"
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
//#endregion
|
|
1769
|
+
//#region src/frame/channel.ts
|
|
1770
|
+
/**
|
|
1771
|
+
* Requests and events between the admin panel and a frame running the site's
|
|
1772
|
+
* code, over one `MessagePort`. The admin panel's side treats everything a
|
|
1773
|
+
* frame sends as untrusted: the site's blocks run there, and could send anything.
|
|
1774
|
+
*/
|
|
1775
|
+
/** The message the admin panel's page sends a frame once it has loaded, with the port to talk on. */
|
|
1776
|
+
const CONNECT_MESSAGE = "goodfellow:connect";
|
|
1777
|
+
/** A request the other side refused or couldn't answer. */
|
|
1778
|
+
var ChannelError = class extends Error {
|
|
1779
|
+
name = "ChannelError";
|
|
1780
|
+
};
|
|
1781
|
+
function isMessage(data) {
|
|
1782
|
+
if (typeof data !== "object" || data === null) return false;
|
|
1783
|
+
const type = data.type;
|
|
1784
|
+
return type === "request" || type === "response" || type === "event";
|
|
1785
|
+
}
|
|
1786
|
+
/** What an error says, as text: errors can't cross the port as they are. */
|
|
1787
|
+
function errorText(error) {
|
|
1788
|
+
return error instanceof Error ? error.message : String(error);
|
|
1789
|
+
}
|
|
1790
|
+
function createChannel(port, handlers = {}) {
|
|
1791
|
+
const own = { ...handlers };
|
|
1792
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1793
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
1794
|
+
let next = 1;
|
|
1795
|
+
let closed = false;
|
|
1796
|
+
port.onmessage = async (event) => {
|
|
1797
|
+
const message = event.data;
|
|
1798
|
+
if (!isMessage(message)) return;
|
|
1799
|
+
if (message.type === "response") {
|
|
1800
|
+
const waiting = pending.get(message.id);
|
|
1801
|
+
if (!waiting) return;
|
|
1802
|
+
pending.delete(message.id);
|
|
1803
|
+
if (message.ok) waiting.resolve(message.result);
|
|
1804
|
+
else waiting.reject(new ChannelError(message.error));
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
if (message.type === "event") {
|
|
1808
|
+
for (const listener of listeners.get(message.name) ?? []) listener(message.payload);
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
const handler = typeof message.method === "string" ? Object.hasOwn(own, message.method) && own[message.method] : void 0;
|
|
1812
|
+
if (!handler) {
|
|
1813
|
+
port.postMessage({
|
|
1814
|
+
type: "response",
|
|
1815
|
+
id: message.id,
|
|
1816
|
+
ok: false,
|
|
1817
|
+
error: `No such request: ${message.method}`
|
|
1818
|
+
});
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
try {
|
|
1822
|
+
const result = await handler(message.params);
|
|
1823
|
+
if (!closed) port.postMessage({
|
|
1824
|
+
type: "response",
|
|
1825
|
+
id: message.id,
|
|
1826
|
+
ok: true,
|
|
1827
|
+
result
|
|
1828
|
+
});
|
|
1829
|
+
} catch (error) {
|
|
1830
|
+
if (!closed) port.postMessage({
|
|
1831
|
+
type: "response",
|
|
1832
|
+
id: message.id,
|
|
1833
|
+
ok: false,
|
|
1834
|
+
error: errorText(error)
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
return {
|
|
1839
|
+
call(method, params, transfer = []) {
|
|
1840
|
+
if (closed) return Promise.reject(new ChannelError("The frame has closed."));
|
|
1841
|
+
const id = next++;
|
|
1842
|
+
return new Promise((resolve, reject) => {
|
|
1843
|
+
pending.set(id, {
|
|
1844
|
+
resolve,
|
|
1845
|
+
reject
|
|
1846
|
+
});
|
|
1847
|
+
port.postMessage({
|
|
1848
|
+
type: "request",
|
|
1849
|
+
id,
|
|
1850
|
+
method,
|
|
1851
|
+
params
|
|
1852
|
+
}, transfer);
|
|
1853
|
+
});
|
|
1854
|
+
},
|
|
1855
|
+
emit(name, payload) {
|
|
1856
|
+
if (!closed) port.postMessage({
|
|
1857
|
+
type: "event",
|
|
1858
|
+
name,
|
|
1859
|
+
payload
|
|
1860
|
+
});
|
|
1861
|
+
},
|
|
1862
|
+
on(name, listener) {
|
|
1863
|
+
const set = listeners.get(name) ?? /* @__PURE__ */ new Set();
|
|
1864
|
+
listeners.set(name, set);
|
|
1865
|
+
set.add(listener);
|
|
1866
|
+
return () => set.delete(listener);
|
|
1867
|
+
},
|
|
1868
|
+
handle(method, handler) {
|
|
1869
|
+
own[method] = handler;
|
|
1870
|
+
return () => {
|
|
1871
|
+
if (own[method] === handler) delete own[method];
|
|
1872
|
+
};
|
|
1873
|
+
},
|
|
1874
|
+
close() {
|
|
1875
|
+
closed = true;
|
|
1876
|
+
port.close();
|
|
1877
|
+
for (const waiting of pending.values()) waiting.reject(new ChannelError("The frame has closed."));
|
|
1878
|
+
pending.clear();
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
//#endregion
|
|
1883
|
+
//#region src/frame/protocol.ts
|
|
1884
|
+
/** A frame sends this to the admin panel's page once it's listening, and the page answers with a port. */
|
|
1885
|
+
const READY_MESSAGE = "goodfellow:frame-ready";
|
|
1886
|
+
//#endregion
|
|
1887
|
+
//#region src/frame/host.tsx
|
|
1888
|
+
/** The only thing frames are allowed: no `allow-same-origin`, so they never reach the admin panel's page or storage. */
|
|
1889
|
+
const FRAME_SANDBOX = "allow-scripts";
|
|
1890
|
+
function frameClient(channel) {
|
|
1891
|
+
return {
|
|
1892
|
+
call: (method, ...params) => channel.call(method, params[0]),
|
|
1893
|
+
on: (name, listener) => channel.on(name, listener)
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
/** How many times a frame may start again, such as after the development server reloads it, before it's given up on. */
|
|
1897
|
+
const MAX_CONNECTIONS = 5;
|
|
1898
|
+
/** Each frame's way of loading its page again, for `reloadFrames()`. */
|
|
1899
|
+
const reloaders = /* @__PURE__ */ new Set();
|
|
1900
|
+
/**
|
|
1901
|
+
* Loads every frame's page again, as development servers do once they've
|
|
1902
|
+
* rebuilt the frames with the site's new code, leaving the admin panel's own
|
|
1903
|
+
* page, and whatever is open on it, as it is. Frames reloaded this way
|
|
1904
|
+
* don't count towards the times one may start again.
|
|
1905
|
+
*/
|
|
1906
|
+
function reloadFrames() {
|
|
1907
|
+
for (const reload of reloaders) reload();
|
|
1908
|
+
}
|
|
1909
|
+
const FrameServicesContext = createContext(null);
|
|
1910
|
+
function FrameServicesProvider({ value, children }) {
|
|
1911
|
+
return /* @__PURE__ */ jsx(FrameServicesContext.Provider, {
|
|
1912
|
+
value,
|
|
1913
|
+
children
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
function useFrameServices() {
|
|
1917
|
+
const value = useContext(FrameServicesContext);
|
|
1918
|
+
if (!value) throw new Error("Frames need the admin panel around them.");
|
|
1919
|
+
return value;
|
|
1920
|
+
}
|
|
1921
|
+
/** Whether a frame asked to go somewhere it may: one of the admin panel's own screens. */
|
|
1922
|
+
function isAdminScreen(href) {
|
|
1923
|
+
return typeof href === "string" && /^#\/[\w\-/?=&%.]*$/.test(href);
|
|
1924
|
+
}
|
|
1925
|
+
/** Makes a frame of the site's code and connects to it. */
|
|
1926
|
+
function useFrame({ mode, onConnect, media }) {
|
|
1927
|
+
const services = useFrameServices();
|
|
1928
|
+
const element = useRef(null);
|
|
1929
|
+
const [client, setClient] = useState();
|
|
1930
|
+
const [failed, setFailed] = useState(false);
|
|
1931
|
+
const latest = useRef({
|
|
1932
|
+
services,
|
|
1933
|
+
onConnect,
|
|
1934
|
+
media
|
|
1935
|
+
});
|
|
1936
|
+
latest.current = {
|
|
1937
|
+
services,
|
|
1938
|
+
onConnect,
|
|
1939
|
+
media
|
|
1940
|
+
};
|
|
1941
|
+
useEffect(() => {
|
|
1942
|
+
let current;
|
|
1943
|
+
let stopRemembering;
|
|
1944
|
+
let connections = 0;
|
|
1945
|
+
let stopped = false;
|
|
1946
|
+
const handlers = {
|
|
1947
|
+
navigate: (href) => {
|
|
1948
|
+
if (isAdminScreen(href)) latest.current.services.navigate(href);
|
|
1949
|
+
},
|
|
1950
|
+
"media.choose": (params) => {
|
|
1951
|
+
const kind = params?.kind;
|
|
1952
|
+
if (kind !== "image" && kind !== "video" && kind !== "file") return null;
|
|
1953
|
+
return latest.current.services.chooseMedia(kind, typeof params.value === "string" ? params.value : "");
|
|
1954
|
+
},
|
|
1955
|
+
"media.bytes": async (url) => {
|
|
1956
|
+
if (typeof url !== "string" || !url.startsWith("/media/") || url.includes("..")) return null;
|
|
1957
|
+
return await (latest.current.media ?? latest.current.services.mediaPreviews).bytes(url) ?? null;
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
const onMessage = (event) => {
|
|
1961
|
+
const frame = element.current?.contentWindow;
|
|
1962
|
+
if (!frame || event.source !== frame || event.data !== "goodfellow:frame-ready") return;
|
|
1963
|
+
current?.close();
|
|
1964
|
+
setClient(void 0);
|
|
1965
|
+
connections += 1;
|
|
1966
|
+
if (connections > MAX_CONNECTIONS) {
|
|
1967
|
+
setFailed(true);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
const { port1, port2 } = new MessageChannel();
|
|
1971
|
+
const channel = createChannel(port1, handlers);
|
|
1972
|
+
current = channel;
|
|
1973
|
+
frame.postMessage({ type: CONNECT_MESSAGE }, "*", [port2]);
|
|
1974
|
+
const next = frameClient(channel);
|
|
1975
|
+
(async () => {
|
|
1976
|
+
try {
|
|
1977
|
+
const { services } = latest.current;
|
|
1978
|
+
const siteCss = mode === "editor" ? await services.siteCss() : void 0;
|
|
1979
|
+
await next.call("init", {
|
|
1980
|
+
...services.init,
|
|
1981
|
+
mode,
|
|
1982
|
+
...siteCss && { siteCss }
|
|
1983
|
+
});
|
|
1984
|
+
const previews = latest.current.media ?? services.mediaPreviews;
|
|
1985
|
+
stopRemembering?.();
|
|
1986
|
+
stopRemembering = previews.onRemember((url, bytes) => {
|
|
1987
|
+
next.call("media.remember", {
|
|
1988
|
+
url,
|
|
1989
|
+
bytes
|
|
1990
|
+
}).catch(() => {});
|
|
1991
|
+
});
|
|
1992
|
+
await latest.current.onConnect?.(next);
|
|
1993
|
+
if (!stopped && current === channel) setClient(next);
|
|
1994
|
+
} catch {
|
|
1995
|
+
if (!stopped && current === channel) setFailed(true);
|
|
1996
|
+
}
|
|
1997
|
+
})();
|
|
1998
|
+
};
|
|
1999
|
+
const reload = () => {
|
|
2000
|
+
connections = 0;
|
|
2001
|
+
setFailed(false);
|
|
2002
|
+
if (element.current) element.current.src = latest.current.services.source.url;
|
|
2003
|
+
};
|
|
2004
|
+
reloaders.add(reload);
|
|
2005
|
+
window.addEventListener("message", onMessage);
|
|
2006
|
+
return () => {
|
|
2007
|
+
stopped = true;
|
|
2008
|
+
reloaders.delete(reload);
|
|
2009
|
+
window.removeEventListener("message", onMessage);
|
|
2010
|
+
stopRemembering?.();
|
|
2011
|
+
current?.close();
|
|
2012
|
+
};
|
|
2013
|
+
}, [mode]);
|
|
2014
|
+
const { source } = services;
|
|
2015
|
+
return {
|
|
2016
|
+
client,
|
|
2017
|
+
failed,
|
|
2018
|
+
frameProps: {
|
|
2019
|
+
ref: useCallback((frame) => {
|
|
2020
|
+
element.current = frame;
|
|
2021
|
+
}, []),
|
|
2022
|
+
sandbox: FRAME_SANDBOX,
|
|
2023
|
+
src: source.url
|
|
2024
|
+
}
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
/** Keeps frames told about the site, the admin panel's light or dark choice, and the like, as they change. */
|
|
2028
|
+
function useFrameUpdates(client) {
|
|
2029
|
+
const { content, media, schedule, theme } = useFrameServices().init;
|
|
2030
|
+
useEffect(() => {
|
|
2031
|
+
client?.call("site", {
|
|
2032
|
+
content,
|
|
2033
|
+
media,
|
|
2034
|
+
schedule
|
|
2035
|
+
}).catch(() => {});
|
|
2036
|
+
}, [
|
|
2037
|
+
client,
|
|
2038
|
+
content,
|
|
2039
|
+
media,
|
|
2040
|
+
schedule
|
|
2041
|
+
]);
|
|
2042
|
+
useEffect(() => {
|
|
2043
|
+
client?.call("theme", theme).catch(() => {});
|
|
2044
|
+
}, [client, theme]);
|
|
2045
|
+
}
|
|
2046
|
+
/** The site's code, run in a hidden frame of its own, for screens that need it without showing anything. */
|
|
2047
|
+
function frameSiteCode(client) {
|
|
2048
|
+
return {
|
|
2049
|
+
async sitePages(content, today) {
|
|
2050
|
+
return (await client()).call("code.sitePages", {
|
|
2051
|
+
content,
|
|
2052
|
+
today
|
|
2053
|
+
});
|
|
2054
|
+
},
|
|
2055
|
+
async pageView(page, content, today) {
|
|
2056
|
+
return await (await client()).call("code.pageView", {
|
|
2057
|
+
page,
|
|
2058
|
+
content,
|
|
2059
|
+
today
|
|
2060
|
+
}) ?? void 0;
|
|
2061
|
+
},
|
|
2062
|
+
async renderedProblems(content, targets, today) {
|
|
2063
|
+
return (await client()).call("code.renderedProblems", {
|
|
2064
|
+
content,
|
|
2065
|
+
targets,
|
|
2066
|
+
today
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
const HIDDEN = {
|
|
2072
|
+
position: "absolute",
|
|
2073
|
+
width: 0,
|
|
2074
|
+
height: 0,
|
|
2075
|
+
border: 0,
|
|
2076
|
+
visibility: "hidden"
|
|
2077
|
+
};
|
|
2078
|
+
/**
|
|
2079
|
+
* The hidden frame `frameSiteCode()` asks. `onClient` gets a function that
|
|
2080
|
+
* answers with the frame once it's connected.
|
|
2081
|
+
*/
|
|
2082
|
+
function SiteCodeFrame({ onClient }) {
|
|
2083
|
+
const { client, frameProps } = useFrame({ mode: "code" });
|
|
2084
|
+
useEffect(() => onClient(client), [client, onClient]);
|
|
2085
|
+
return /* @__PURE__ */ jsx("iframe", {
|
|
2086
|
+
title: "",
|
|
2087
|
+
"aria-hidden": "true",
|
|
2088
|
+
tabIndex: -1,
|
|
2089
|
+
style: HIDDEN,
|
|
2090
|
+
...frameProps
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
//#endregion
|
|
2094
|
+
//#region src/publish-failure.tsx
|
|
2095
|
+
/** Explains why a publish from a dialog or form didn't go through. */
|
|
2096
|
+
function PublishFailure({ failure }) {
|
|
2097
|
+
const t = useStrings();
|
|
2098
|
+
if (!failure || failure.reason === "checks") return null;
|
|
2099
|
+
return /* @__PURE__ */ jsx(ErrorMessage, {
|
|
2100
|
+
message: t(failure.reason === "conflict" ? "publish.conflict" : "publish.error"),
|
|
2101
|
+
error: failure.error
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
//#endregion
|
|
2105
|
+
//#region src/site-host.ts
|
|
2106
|
+
/**
|
|
2107
|
+
* The host outside GitHub and GitLab that builds the site, as its setup files
|
|
2108
|
+
* say, such as Cloudflare Pages: `undefined` while they're read, `null` for none.
|
|
2109
|
+
* They're outside the folders the site's files are read from, so they're read
|
|
2110
|
+
* as version history reads files, at the loaded revision.
|
|
2111
|
+
*/
|
|
2112
|
+
function useOutsideHost() {
|
|
2113
|
+
const { readFile, versions, state } = useAdmin();
|
|
2114
|
+
const revision = state.status === "ready" ? state.revision : void 0;
|
|
2115
|
+
const [host, setHost] = useState();
|
|
2116
|
+
useEffect(() => {
|
|
2117
|
+
let cancelled = false;
|
|
2118
|
+
outsideHost(versions && revision ? (path) => versions.readAt(path, revision) : readFile).then((found) => !cancelled && setHost(found ?? null), () => !cancelled && setHost(null));
|
|
2119
|
+
return () => {
|
|
2120
|
+
cancelled = true;
|
|
2121
|
+
};
|
|
2122
|
+
}, [
|
|
2123
|
+
readFile,
|
|
2124
|
+
versions,
|
|
2125
|
+
revision
|
|
2126
|
+
]);
|
|
2127
|
+
return host;
|
|
2128
|
+
}
|
|
2129
|
+
//#endregion
|
|
2130
|
+
//#region src/upload.ts
|
|
2131
|
+
/** Photos are made no larger than this on their longest side, which is plenty for any screen. */
|
|
2132
|
+
const MAX_IMAGE_SIDE = 2400;
|
|
2133
|
+
var UploadError = class extends Error {
|
|
2134
|
+
problem;
|
|
2135
|
+
file;
|
|
2136
|
+
name = "UploadError";
|
|
2137
|
+
constructor(problem, file, message) {
|
|
2138
|
+
super(message);
|
|
2139
|
+
this.problem = problem;
|
|
2140
|
+
this.file = file;
|
|
2141
|
+
}
|
|
2142
|
+
};
|
|
2143
|
+
const REENCODED = {
|
|
2144
|
+
jpg: "image/jpeg",
|
|
2145
|
+
jpeg: "image/jpeg",
|
|
2146
|
+
png: "image/png",
|
|
2147
|
+
webp: "image/webp"
|
|
2148
|
+
};
|
|
2149
|
+
async function canvasBlob(canvas, type) {
|
|
2150
|
+
return new Promise((resolve) => canvas.toBlob(resolve, type, .85));
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Re-saves a photo: smaller if it's larger than any screen needs, and always
|
|
2154
|
+
* for JPEGs, which drops details hidden in the file such as where it was taken.
|
|
2155
|
+
* Returns `undefined` to keep the file as it is.
|
|
2156
|
+
*/
|
|
2157
|
+
async function resized(file, extension) {
|
|
2158
|
+
const type = REENCODED[extension];
|
|
2159
|
+
if (!type || typeof createImageBitmap !== "function") return void 0;
|
|
2160
|
+
let bitmap;
|
|
2161
|
+
try {
|
|
2162
|
+
bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
2163
|
+
} catch {
|
|
2164
|
+
throw new UploadError("unreadable", file.name, `${file.name} couldn't be read as an image.`);
|
|
2165
|
+
}
|
|
2166
|
+
const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
|
|
2167
|
+
if (scale === 1 && type !== "image/jpeg") {
|
|
2168
|
+
bitmap.close();
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
const canvas = document.createElement("canvas");
|
|
2172
|
+
canvas.width = Math.round(bitmap.width * scale);
|
|
2173
|
+
canvas.height = Math.round(bitmap.height * scale);
|
|
2174
|
+
canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
2175
|
+
bitmap.close();
|
|
2176
|
+
const blob = await canvasBlob(canvas, type);
|
|
2177
|
+
return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
|
|
2178
|
+
}
|
|
2179
|
+
const UNSAFE_SVG_ELEMENTS = [
|
|
2180
|
+
"script",
|
|
2181
|
+
"foreignObject",
|
|
2182
|
+
"iframe",
|
|
2183
|
+
"embed",
|
|
2184
|
+
"object",
|
|
2185
|
+
"audio",
|
|
2186
|
+
"video"
|
|
2187
|
+
];
|
|
2188
|
+
/**
|
|
2189
|
+
* Removes anything from an SVG that could run code if someone opens the
|
|
2190
|
+
* file directly: scripts, embedded pages, event handlers, and javascript:, vbscript:
|
|
2191
|
+
* and data: links other than images.
|
|
2192
|
+
*/
|
|
2193
|
+
function cleanSvg(text) {
|
|
2194
|
+
const doc = new DOMParser().parseFromString(text, "image/svg+xml");
|
|
2195
|
+
if (doc.querySelector("parsererror") || doc.documentElement.nodeName.toLowerCase() !== "svg") return void 0;
|
|
2196
|
+
for (const name of UNSAFE_SVG_ELEMENTS) for (const element of [...doc.getElementsByTagName(name)]) element.remove();
|
|
2197
|
+
for (const element of [doc.documentElement, ...doc.documentElement.querySelectorAll("*")]) for (const attribute of [...element.attributes]) {
|
|
2198
|
+
const value = attribute.value.trim().toLowerCase();
|
|
2199
|
+
if (attribute.name.toLowerCase().startsWith("on") || value.startsWith("javascript:") || value.startsWith("vbscript:") || value.startsWith("data:") && !value.startsWith("data:image/")) element.removeAttribute(attribute.name);
|
|
2200
|
+
}
|
|
2201
|
+
return new XMLSerializer().serializeToString(doc);
|
|
2202
|
+
}
|
|
2203
|
+
/** Checks a file chosen for upload and gets it ready: photos made web-sized, SVGs made safe. */
|
|
2204
|
+
async function prepareUpload(file) {
|
|
2205
|
+
const extension = extensionOf(file.name);
|
|
2206
|
+
if (!mediaKind$1(file.name)) throw new UploadError("type", file.name, `${file.name} isn't a kind of file the site can use.`);
|
|
2207
|
+
let blob = file;
|
|
2208
|
+
if (extension === "svg") {
|
|
2209
|
+
const cleaned = cleanSvg(await file.text());
|
|
2210
|
+
if (cleaned === void 0) throw new UploadError("unreadable", file.name, `${file.name} isn't a valid SVG image.`);
|
|
2211
|
+
blob = new Blob([cleaned], { type: "image/svg+xml" });
|
|
2212
|
+
} else blob = await resized(file, extension) ?? file;
|
|
2213
|
+
if (blob.size > MAX_UPLOAD_BYTES) throw new UploadError("size", file.name, `${file.name} is ${blob.size} bytes, over the ${MAX_UPLOAD_BYTES}-byte limit.`);
|
|
2214
|
+
return {
|
|
2215
|
+
name: file.name,
|
|
2216
|
+
bytes: new Uint8Array(await blob.arrayBuffer()),
|
|
2217
|
+
type: blob.type || file.type
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
//#endregion
|
|
2221
|
+
//#region src/media-library.tsx
|
|
2222
|
+
/** A media file's picture: the image itself, or its kind of file for anything else. */
|
|
2223
|
+
function MediaThumb({ url, className }) {
|
|
2224
|
+
const { mediaPreviews } = useAdmin();
|
|
2225
|
+
const [src, setSrc] = useState(() => mediaPreviews.resolve(url));
|
|
2226
|
+
const tried = useRef(false);
|
|
2227
|
+
if (mediaKind$1(url) !== "image") return /* @__PURE__ */ jsx("span", {
|
|
2228
|
+
className: `gfa-media-thumb gfa-media-file ${className ?? ""}`,
|
|
2229
|
+
"aria-hidden": "true",
|
|
2230
|
+
children: extensionOf(url).toUpperCase()
|
|
2231
|
+
});
|
|
2232
|
+
return /* @__PURE__ */ jsx("img", {
|
|
2233
|
+
className: `gfa-media-thumb ${className ?? ""}`,
|
|
2234
|
+
src,
|
|
2235
|
+
alt: "",
|
|
2236
|
+
loading: "lazy",
|
|
2237
|
+
onError: () => {
|
|
2238
|
+
if (tried.current) return;
|
|
2239
|
+
tried.current = true;
|
|
2240
|
+
mediaPreviews.load(url).then((loaded) => loaded && setSrc(loaded));
|
|
2241
|
+
}
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
/** A button that opens the file chooser. */
|
|
2245
|
+
function UploadButton({ label, multiple = true, accept, disabled, variant = "primary", onFiles }) {
|
|
2246
|
+
const input = useRef(null);
|
|
2247
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
|
|
2248
|
+
variant,
|
|
2249
|
+
disabled,
|
|
2250
|
+
onClick: () => input.current?.click(),
|
|
2251
|
+
children: label
|
|
2252
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
2253
|
+
ref: input,
|
|
2254
|
+
type: "file",
|
|
2255
|
+
hidden: true,
|
|
2256
|
+
multiple,
|
|
2257
|
+
accept,
|
|
2258
|
+
"aria-label": label,
|
|
2259
|
+
onChange: (event) => {
|
|
2260
|
+
const files = [...event.target.files ?? []];
|
|
2261
|
+
event.target.value = "";
|
|
2262
|
+
if (files.length > 0) onFiles(files);
|
|
2263
|
+
}
|
|
2264
|
+
})] });
|
|
2265
|
+
}
|
|
2266
|
+
const uploadProblems = {
|
|
2267
|
+
type: "media.error.type",
|
|
2268
|
+
size: "media.error.size",
|
|
2269
|
+
unreadable: "media.error.unreadable"
|
|
2270
|
+
};
|
|
2271
|
+
/**
|
|
2272
|
+
* Uploads files to the media folder in one publish, and shows them in the
|
|
2273
|
+
* admin panel straight away. `replace` overwrites one existing file instead.
|
|
2274
|
+
*/
|
|
2275
|
+
function useUploadMedia() {
|
|
2276
|
+
const t = useStrings();
|
|
2277
|
+
const { publish, mediaPreviews } = useAdmin();
|
|
2278
|
+
const { content, media } = useSiteContent();
|
|
2279
|
+
return async (files, replace) => {
|
|
2280
|
+
const taken = media.map(fileName);
|
|
2281
|
+
const changes = [];
|
|
2282
|
+
const uploads = [];
|
|
2283
|
+
for (const file of files) {
|
|
2284
|
+
let prepared;
|
|
2285
|
+
try {
|
|
2286
|
+
prepared = await prepareUpload(file);
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
return {
|
|
2289
|
+
ok: false,
|
|
2290
|
+
message: error instanceof UploadError ? t(uploadProblems[error.problem], { name: error.file }) : String(error)
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
const path = replace ?? `${MEDIA_DIR}/${uploadName(file.name, taken)}`;
|
|
2294
|
+
taken.push(fileName(path));
|
|
2295
|
+
changes.push({
|
|
2296
|
+
path,
|
|
2297
|
+
bytes: prepared.bytes
|
|
2298
|
+
});
|
|
2299
|
+
uploads.push({
|
|
2300
|
+
url: mediaUrl(path),
|
|
2301
|
+
bytes: prepared.bytes
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
for (const upload of uploads) mediaPreviews.remember(upload.url, upload.bytes);
|
|
2305
|
+
const names = changes.map((change) => fileName(change.path)).join(", ");
|
|
2306
|
+
const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
|
|
2307
|
+
const result = await publish(credits ? [...changes, credits] : changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), { checks: false });
|
|
2308
|
+
if (!result.ok) return {
|
|
2309
|
+
ok: false,
|
|
2310
|
+
message: t(result.reason === "conflict" ? "publish.conflict" : "publish.error"),
|
|
2311
|
+
failure: result
|
|
2312
|
+
};
|
|
2313
|
+
return {
|
|
2314
|
+
ok: true,
|
|
2315
|
+
urls: uploads.map((upload) => upload.url)
|
|
2316
|
+
};
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
function DeleteMediaDialog({ url, onClose }) {
|
|
2320
|
+
const t = useStrings();
|
|
2321
|
+
const { publish } = useAdmin();
|
|
2322
|
+
const { content } = useSiteContent();
|
|
2323
|
+
const [busy, setBusy] = useState(false);
|
|
2324
|
+
const [failure, setFailure] = useState(null);
|
|
2325
|
+
const name = fileName(url);
|
|
2326
|
+
const used = mediaUsage(content, url, {
|
|
2327
|
+
header: t("layout.header"),
|
|
2328
|
+
footer: t("layout.footer"),
|
|
2329
|
+
settings: t("nav.settings"),
|
|
2330
|
+
css: t("settings.tab.css")
|
|
2331
|
+
});
|
|
2332
|
+
const onDelete = async () => {
|
|
2333
|
+
const path = mediaPath(url);
|
|
2334
|
+
if (!path) return;
|
|
2335
|
+
setBusy(true);
|
|
2336
|
+
const credits = withoutCredits(content.credits, [url]);
|
|
2337
|
+
const result = await publish([{
|
|
2338
|
+
path,
|
|
2339
|
+
delete: true
|
|
2340
|
+
}, ...credits ? [credits] : []], t("media.deleteMessage", { name }));
|
|
2341
|
+
setBusy(false);
|
|
2342
|
+
if (result.ok) onClose();
|
|
2343
|
+
else setFailure(result);
|
|
2344
|
+
};
|
|
2345
|
+
return /* @__PURE__ */ jsxs(Dialog, {
|
|
2346
|
+
title: t("media.deleteTitle", { name }),
|
|
2347
|
+
onClose,
|
|
2348
|
+
children: [
|
|
2349
|
+
/* @__PURE__ */ jsx("p", { children: t("media.deleteBody", { name }) }),
|
|
2350
|
+
/* @__PURE__ */ jsx("p", { children: used.length > 0 ? t("media.deleteUsed", { places: used.join(", ") }) : t("media.notUsed") }),
|
|
2351
|
+
/* @__PURE__ */ jsx(PublishFailure, { failure }),
|
|
2352
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2353
|
+
className: "gfa-dialog-actions",
|
|
2354
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
2355
|
+
onClick: onClose,
|
|
2356
|
+
children: t("action.cancel")
|
|
2357
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
2358
|
+
variant: "danger",
|
|
2359
|
+
disabled: busy,
|
|
2360
|
+
onClick: () => void onDelete(),
|
|
2361
|
+
children: busy ? t("publish.publishing") : t("media.delete")
|
|
2362
|
+
})]
|
|
2363
|
+
})
|
|
2364
|
+
]
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
/** Who made a photo and its license, which pages that use it show. */
|
|
2368
|
+
function MediaCredit({ credit }) {
|
|
2369
|
+
const t = useStrings();
|
|
2370
|
+
const text = [credit.creator, credit.license].filter(Boolean).join(" · ");
|
|
2371
|
+
return /* @__PURE__ */ jsx("span", {
|
|
2372
|
+
className: "gfa-media-credit",
|
|
2373
|
+
title: [credit.title, credit.source].filter(Boolean).join(" · "),
|
|
2374
|
+
children: credit.sourceUrl ? /* @__PURE__ */ jsx("a", {
|
|
2375
|
+
href: credit.sourceUrl,
|
|
2376
|
+
target: "_blank",
|
|
2377
|
+
rel: "noreferrer",
|
|
2378
|
+
children: t("media.credit", { credit: text })
|
|
2379
|
+
}) : t("media.credit", { credit: text })
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
/** Lets files be dropped onto an area to upload them. */
|
|
2383
|
+
function DropZone({ onFiles, children }) {
|
|
2384
|
+
const t = useStrings();
|
|
2385
|
+
const [over, setOver] = useState(false);
|
|
2386
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
2387
|
+
className: `gfa-dropzone${over ? " gfa-dropzone-over" : ""}`,
|
|
2388
|
+
"aria-label": t("media.drop"),
|
|
2389
|
+
onDragOver: (event) => {
|
|
2390
|
+
if (!event.dataTransfer.types.includes("Files")) return;
|
|
2391
|
+
event.preventDefault();
|
|
2392
|
+
setOver(true);
|
|
2393
|
+
},
|
|
2394
|
+
onDragLeave: () => setOver(false),
|
|
2395
|
+
onDrop: (event) => {
|
|
2396
|
+
event.preventDefault();
|
|
2397
|
+
setOver(false);
|
|
2398
|
+
const files = [...event.dataTransfer.files];
|
|
2399
|
+
if (files.length > 0) onFiles(files);
|
|
2400
|
+
},
|
|
2401
|
+
children: [children, over && /* @__PURE__ */ jsx("p", {
|
|
2402
|
+
className: "gfa-dropzone-hint",
|
|
2403
|
+
children: t("media.drop")
|
|
2404
|
+
})]
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
function UploadStatus({ status }) {
|
|
2408
|
+
const t = useStrings();
|
|
2409
|
+
if (status.type === "uploading") return /* @__PURE__ */ jsx("p", {
|
|
2410
|
+
className: "gfa-notice",
|
|
2411
|
+
children: t("media.uploading")
|
|
2412
|
+
});
|
|
2413
|
+
if (status.type === "done") return /* @__PURE__ */ jsx("p", {
|
|
2414
|
+
className: "gfa-notice gfa-notice-success",
|
|
2415
|
+
role: "status",
|
|
2416
|
+
children: t("media.uploaded")
|
|
2417
|
+
});
|
|
2418
|
+
if (status.type === "failed") return status.failure ? /* @__PURE__ */ jsx(PublishFailure, { failure: status.failure }) : /* @__PURE__ */ jsx(ErrorMessage, { message: status.message });
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
function useUploads() {
|
|
2422
|
+
const upload = useUploadMedia();
|
|
2423
|
+
const [status, setStatus] = useState({ type: "idle" });
|
|
2424
|
+
const run = async (files, replace) => {
|
|
2425
|
+
setStatus({ type: "uploading" });
|
|
2426
|
+
const result = await upload(files, replace);
|
|
2427
|
+
setStatus(result.ok ? { type: "done" } : {
|
|
2428
|
+
type: "failed",
|
|
2429
|
+
message: result.message,
|
|
2430
|
+
failure: result.failure
|
|
2431
|
+
});
|
|
2432
|
+
return result;
|
|
2433
|
+
};
|
|
2434
|
+
return {
|
|
2435
|
+
status,
|
|
2436
|
+
run
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
/** Every uploaded image and file, with buttons to upload, replace and delete them. */
|
|
2440
|
+
/** Cloudflare's terms for its CDN, which limit serving video on its free plans. */
|
|
2441
|
+
const CLOUDFLARE_TERMS = "https://www.cloudflare.com/service-specific-terms-application-services/";
|
|
2442
|
+
function MediaScreen() {
|
|
2443
|
+
const t = useStrings();
|
|
2444
|
+
const { content, media } = useSiteContent();
|
|
2445
|
+
const { status, run } = useUploads();
|
|
2446
|
+
const [query, setQuery] = useState("");
|
|
2447
|
+
const [deleting, setDeleting] = useState(null);
|
|
2448
|
+
const [copied, setCopied] = useState(null);
|
|
2449
|
+
const searchId = useId();
|
|
2450
|
+
useEffect(() => {
|
|
2451
|
+
const stop = (event) => {
|
|
2452
|
+
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
|
|
2453
|
+
};
|
|
2454
|
+
window.addEventListener("dragover", stop);
|
|
2455
|
+
window.addEventListener("drop", stop);
|
|
2456
|
+
return () => {
|
|
2457
|
+
window.removeEventListener("dragover", stop);
|
|
2458
|
+
window.removeEventListener("drop", stop);
|
|
2459
|
+
};
|
|
2460
|
+
}, []);
|
|
2461
|
+
const cloudflareVideos = useOutsideHost() === "cloudflare-pages" && media.some((path) => mediaKind$1(mediaUrl(path)) === "video");
|
|
2462
|
+
const urls = useMemo(() => media.map(mediaUrl).filter((url) => fileName(url).toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => fileName(a).localeCompare(fileName(b))), [media, query]);
|
|
2463
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2464
|
+
className: "gfa-screen gfa-screen-wide",
|
|
2465
|
+
children: [
|
|
2466
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2467
|
+
className: "gfa-screen-header",
|
|
2468
|
+
children: [/* @__PURE__ */ jsx("h1", { children: t("media.title") }), /* @__PURE__ */ jsx(UploadButton, {
|
|
2469
|
+
label: t("media.upload"),
|
|
2470
|
+
disabled: status.type === "uploading",
|
|
2471
|
+
onFiles: (files) => void run(files)
|
|
2472
|
+
})]
|
|
2473
|
+
}),
|
|
2474
|
+
/* @__PURE__ */ jsx("p", {
|
|
2475
|
+
className: "gfa-hint",
|
|
2476
|
+
children: t("media.intro")
|
|
2477
|
+
}),
|
|
2478
|
+
cloudflareVideos && /* @__PURE__ */ jsxs("p", {
|
|
2479
|
+
className: "gfa-notice",
|
|
2480
|
+
role: "note",
|
|
2481
|
+
children: [
|
|
2482
|
+
t("media.cloudflareVideo"),
|
|
2483
|
+
" ",
|
|
2484
|
+
/* @__PURE__ */ jsx("a", {
|
|
2485
|
+
href: CLOUDFLARE_TERMS,
|
|
2486
|
+
target: "_blank",
|
|
2487
|
+
rel: "noreferrer",
|
|
2488
|
+
children: t("media.cloudflareVideoTerms")
|
|
2489
|
+
})
|
|
2490
|
+
]
|
|
2491
|
+
}),
|
|
2492
|
+
/* @__PURE__ */ jsx(UploadStatus, { status }),
|
|
2493
|
+
media.length > 0 && /* @__PURE__ */ jsxs("div", {
|
|
2494
|
+
className: "gfa-field gfa-media-search",
|
|
2495
|
+
children: [/* @__PURE__ */ jsx("label", {
|
|
2496
|
+
htmlFor: searchId,
|
|
2497
|
+
className: "gfa-label",
|
|
2498
|
+
children: t("media.search")
|
|
2499
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
2500
|
+
id: searchId,
|
|
2501
|
+
className: "gfa-input",
|
|
2502
|
+
type: "search",
|
|
2503
|
+
value: query,
|
|
2504
|
+
onChange: (event) => setQuery(event.target.value)
|
|
2505
|
+
})]
|
|
2506
|
+
}),
|
|
2507
|
+
/* @__PURE__ */ jsx(DropZone, {
|
|
2508
|
+
onFiles: (files) => void run(files),
|
|
2509
|
+
children: media.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("media.empty") }) : urls.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("media.noMatches") }) : /* @__PURE__ */ jsx("ul", {
|
|
2510
|
+
className: "gfa-media-grid",
|
|
2511
|
+
children: urls.map((url) => /* @__PURE__ */ jsxs("li", {
|
|
2512
|
+
className: "gfa-media-card",
|
|
2513
|
+
children: [
|
|
2514
|
+
/* @__PURE__ */ jsx(MediaThumb, { url }),
|
|
2515
|
+
/* @__PURE__ */ jsx("span", {
|
|
2516
|
+
className: "gfa-media-name",
|
|
2517
|
+
title: url,
|
|
2518
|
+
children: fileName(url)
|
|
2519
|
+
}),
|
|
2520
|
+
content.credits?.[url] && /* @__PURE__ */ jsx(MediaCredit, { credit: content.credits[url] }),
|
|
2521
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2522
|
+
className: "gfa-media-actions",
|
|
2523
|
+
children: [
|
|
2524
|
+
/* @__PURE__ */ jsx(Button, {
|
|
2525
|
+
variant: "ghost",
|
|
2526
|
+
onClick: () => void navigator.clipboard?.writeText(url).then(() => setCopied(url), () => setCopied(null)),
|
|
2527
|
+
children: copied === url ? t("media.copied") : t("media.copy")
|
|
2528
|
+
}),
|
|
2529
|
+
/* @__PURE__ */ jsx(UploadButton, {
|
|
2530
|
+
label: t("media.replace"),
|
|
2531
|
+
variant: "secondary",
|
|
2532
|
+
multiple: false,
|
|
2533
|
+
disabled: status.type === "uploading",
|
|
2534
|
+
onFiles: (files) => void run(files.slice(0, 1), mediaPath(url))
|
|
2535
|
+
}),
|
|
2536
|
+
/* @__PURE__ */ jsx(Button, {
|
|
2537
|
+
variant: "ghost",
|
|
2538
|
+
onClick: () => setDeleting(url),
|
|
2539
|
+
children: t("media.delete")
|
|
2540
|
+
})
|
|
2541
|
+
]
|
|
2542
|
+
})
|
|
2543
|
+
]
|
|
2544
|
+
}, url))
|
|
2545
|
+
})
|
|
2546
|
+
}),
|
|
2547
|
+
deleting && /* @__PURE__ */ jsx(DeleteMediaDialog, {
|
|
2548
|
+
url: deleting,
|
|
2549
|
+
onClose: () => setDeleting(null)
|
|
2550
|
+
})
|
|
2551
|
+
]
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
const PICKER_TITLES = {
|
|
2555
|
+
image: "media.pickerTitle",
|
|
2556
|
+
video: "media.pickerTitleVideo",
|
|
2557
|
+
file: "media.pickerTitleFile"
|
|
2558
|
+
};
|
|
2559
|
+
const CHOOSE_LABELS = {
|
|
2560
|
+
image: "media.choose",
|
|
2561
|
+
video: "media.chooseVideo",
|
|
2562
|
+
file: "media.chooseFile"
|
|
2563
|
+
};
|
|
2564
|
+
const ACCEPT = {
|
|
2565
|
+
image: "image/*",
|
|
2566
|
+
video: "video/mp4,video/webm",
|
|
2567
|
+
file: void 0
|
|
2568
|
+
};
|
|
2569
|
+
/** Picks a file from the media library, or uploads a new one, for a field. */
|
|
2570
|
+
function MediaPickerDialog({ kind, onSelect, onClose }) {
|
|
2571
|
+
const t = useStrings();
|
|
2572
|
+
const { media } = useSiteContent();
|
|
2573
|
+
const { status, run } = useUploads();
|
|
2574
|
+
const urls = media.map(mediaUrl).filter((url) => kind === "file" || mediaKind$1(url) === kind).sort((a, b) => fileName(a).localeCompare(fileName(b)));
|
|
2575
|
+
return /* @__PURE__ */ jsx(Dialog, {
|
|
2576
|
+
title: t(PICKER_TITLES[kind]),
|
|
2577
|
+
onClose,
|
|
2578
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
2579
|
+
className: "gfa-form",
|
|
2580
|
+
children: [
|
|
2581
|
+
/* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(UploadButton, {
|
|
2582
|
+
label: t("media.upload"),
|
|
2583
|
+
multiple: false,
|
|
2584
|
+
accept: ACCEPT[kind],
|
|
2585
|
+
disabled: status.type === "uploading",
|
|
2586
|
+
onFiles: async (files) => {
|
|
2587
|
+
const result = await run(files.slice(0, 1));
|
|
2588
|
+
const [url] = result.ok ? result.urls : [];
|
|
2589
|
+
if (url) {
|
|
2590
|
+
onSelect(url);
|
|
2591
|
+
onClose();
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
}) }),
|
|
2595
|
+
/* @__PURE__ */ jsx(UploadStatus, { status }),
|
|
2596
|
+
urls.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
2597
|
+
className: "gfa-hint",
|
|
2598
|
+
children: t("media.pickerEmpty")
|
|
2599
|
+
}) : /* @__PURE__ */ jsx("ul", {
|
|
2600
|
+
className: "gfa-media-grid gfa-media-picker",
|
|
2601
|
+
children: urls.map((url) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
|
|
2602
|
+
type: "button",
|
|
2603
|
+
className: "gfa-media-card gfa-media-pick",
|
|
2604
|
+
onClick: () => {
|
|
2605
|
+
onSelect(url);
|
|
2606
|
+
onClose();
|
|
2607
|
+
},
|
|
2608
|
+
children: [/* @__PURE__ */ jsx(MediaThumb, { url }), /* @__PURE__ */ jsx("span", {
|
|
2609
|
+
className: "gfa-media-name",
|
|
2610
|
+
children: fileName(url)
|
|
2611
|
+
})]
|
|
2612
|
+
}) }, url))
|
|
2613
|
+
}),
|
|
2614
|
+
/* @__PURE__ */ jsx("div", {
|
|
2615
|
+
className: "gfa-dialog-actions",
|
|
2616
|
+
children: /* @__PURE__ */ jsx(Button, {
|
|
2617
|
+
onClick: onClose,
|
|
2618
|
+
children: t("action.cancel")
|
|
2619
|
+
})
|
|
2620
|
+
})
|
|
2621
|
+
]
|
|
2622
|
+
})
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Where choosing a file happens, where it isn't here: in the site's frames,
|
|
2627
|
+
* the media library opens in the admin panel's own page, which can upload.
|
|
2628
|
+
*/
|
|
2629
|
+
const MediaChoiceContext = createContext(void 0);
|
|
2630
|
+
/** The picture of the chosen file and a button to choose another, shown with a field that holds a media address. */
|
|
2631
|
+
function MediaChooser({ value, kind, onChange }) {
|
|
2632
|
+
const t = useStrings();
|
|
2633
|
+
const [open, setOpen] = useState(false);
|
|
2634
|
+
const chooseElsewhere = useContext(MediaChoiceContext);
|
|
2635
|
+
const choose = () => {
|
|
2636
|
+
if (!chooseElsewhere) setOpen(true);
|
|
2637
|
+
else chooseElsewhere(kind, value).then((url) => url !== null && onChange(url));
|
|
2638
|
+
};
|
|
2639
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2640
|
+
className: "gfa-media-chooser",
|
|
2641
|
+
children: [
|
|
2642
|
+
value && /* @__PURE__ */ jsx(MediaThumb, {
|
|
2643
|
+
url: value,
|
|
2644
|
+
className: "gfa-media-chosen"
|
|
2645
|
+
}, value),
|
|
2646
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2647
|
+
className: "gfa-media-chooser-actions",
|
|
2648
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
2649
|
+
onClick: choose,
|
|
2650
|
+
children: t(CHOOSE_LABELS[kind])
|
|
2651
|
+
}), value && /* @__PURE__ */ jsx(Button, {
|
|
2652
|
+
variant: "ghost",
|
|
2653
|
+
onClick: () => onChange(""),
|
|
2654
|
+
children: t("media.clear")
|
|
2655
|
+
})]
|
|
2656
|
+
}),
|
|
2657
|
+
open && /* @__PURE__ */ jsx(MediaPickerDialog, {
|
|
2658
|
+
kind,
|
|
2659
|
+
onSelect: onChange,
|
|
2660
|
+
onClose: () => setOpen(false)
|
|
2661
|
+
})
|
|
2662
|
+
]
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
/** A text field for a media address, with the media chooser below it. For the admin panel's own forms. */
|
|
2666
|
+
function MediaField({ label, hint, error, value, kind = "image", onChange }) {
|
|
2667
|
+
return /* @__PURE__ */ jsx(Field, {
|
|
2668
|
+
label,
|
|
2669
|
+
hint,
|
|
2670
|
+
error,
|
|
2671
|
+
children: (props) => /* @__PURE__ */ jsxs("div", {
|
|
2672
|
+
className: "gfa-media-field",
|
|
2673
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
2674
|
+
...props,
|
|
2675
|
+
className: "gfa-input",
|
|
2676
|
+
value,
|
|
2677
|
+
onChange: (event) => onChange(event.target.value)
|
|
2678
|
+
}), /* @__PURE__ */ jsx(MediaChooser, {
|
|
2679
|
+
value,
|
|
2680
|
+
kind,
|
|
2681
|
+
onChange
|
|
2682
|
+
})]
|
|
2683
|
+
})
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
//#endregion
|
|
2687
|
+
//#region src/media-previews.ts
|
|
2688
|
+
const TYPES = {
|
|
2689
|
+
jpg: "image/jpeg",
|
|
2690
|
+
jpeg: "image/jpeg",
|
|
2691
|
+
png: "image/png",
|
|
2692
|
+
gif: "image/gif",
|
|
2693
|
+
webp: "image/webp",
|
|
2694
|
+
avif: "image/avif",
|
|
2695
|
+
svg: "image/svg+xml",
|
|
2696
|
+
ico: "image/x-icon",
|
|
2697
|
+
pdf: "application/pdf",
|
|
2698
|
+
mp3: "audio/mpeg",
|
|
2699
|
+
m4a: "audio/mp4",
|
|
2700
|
+
mp4: "video/mp4",
|
|
2701
|
+
webm: "video/webm",
|
|
2702
|
+
vtt: "text/vtt"
|
|
2703
|
+
};
|
|
2704
|
+
/** The type to show a file as, from its name. Needed so browsers display SVGs from memory. */
|
|
2705
|
+
function mediaType(name) {
|
|
2706
|
+
return TYPES[extensionOf(name)] ?? "application/octet-stream";
|
|
2707
|
+
}
|
|
2708
|
+
function createMediaPreviews(store, siteUrl) {
|
|
2709
|
+
const local = /* @__PURE__ */ new Map();
|
|
2710
|
+
const remembered = /* @__PURE__ */ new Map();
|
|
2711
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2712
|
+
const loading = /* @__PURE__ */ new Map();
|
|
2713
|
+
const base = trimChars(siteUrl, "/", { start: false });
|
|
2714
|
+
const remember = (url, bytes) => {
|
|
2715
|
+
const previous = local.get(url);
|
|
2716
|
+
if (previous) URL.revokeObjectURL(previous);
|
|
2717
|
+
remembered.set(url, bytes);
|
|
2718
|
+
for (const listener of listeners) listener(url, bytes);
|
|
2719
|
+
local.set(url, URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mediaType(url) })));
|
|
2720
|
+
};
|
|
2721
|
+
return {
|
|
2722
|
+
resolve(url) {
|
|
2723
|
+
if (!url.startsWith("/media/")) return url;
|
|
2724
|
+
return local.get(url) ?? `${base}${url}`;
|
|
2725
|
+
},
|
|
2726
|
+
remember,
|
|
2727
|
+
onRemember(listener) {
|
|
2728
|
+
for (const [url, bytes] of remembered) listener(url, bytes);
|
|
2729
|
+
listeners.add(listener);
|
|
2730
|
+
return () => listeners.delete(listener);
|
|
2731
|
+
},
|
|
2732
|
+
async bytes(url) {
|
|
2733
|
+
const known = remembered.get(url);
|
|
2734
|
+
if (known) return known;
|
|
2735
|
+
const path = mediaPath(url);
|
|
2736
|
+
return path ? await store.readBytes(path).catch(() => void 0) : void 0;
|
|
2737
|
+
},
|
|
2738
|
+
load(url) {
|
|
2739
|
+
const known = local.get(url);
|
|
2740
|
+
if (known) return Promise.resolve(known);
|
|
2741
|
+
const path = mediaPath(url);
|
|
2742
|
+
if (!path) return Promise.resolve(void 0);
|
|
2743
|
+
let pending = loading.get(url);
|
|
2744
|
+
if (!pending) {
|
|
2745
|
+
pending = store.readBytes(path).then((bytes) => {
|
|
2746
|
+
if (!bytes) return void 0;
|
|
2747
|
+
remember(url, bytes);
|
|
2748
|
+
return local.get(url);
|
|
2749
|
+
}).catch(() => void 0);
|
|
2750
|
+
loading.set(url, pending);
|
|
2751
|
+
}
|
|
2752
|
+
return pending;
|
|
2753
|
+
}
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
const URL_ATTRIBUTES = [
|
|
2757
|
+
"src",
|
|
2758
|
+
"href",
|
|
2759
|
+
"poster"
|
|
2760
|
+
];
|
|
2761
|
+
const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
|
|
2762
|
+
/**
|
|
2763
|
+
* Keeps media in a preview document showing: rewrites `/media/…` addresses in
|
|
2764
|
+
* images, links and inline styles as the preview changes, and if an image
|
|
2765
|
+
* still doesn't load, reads it from the repository instead.
|
|
2766
|
+
*/
|
|
2767
|
+
function showMediaInPreview(doc, previews) {
|
|
2768
|
+
const fix = (element) => {
|
|
2769
|
+
for (const attribute of URL_ATTRIBUTES) {
|
|
2770
|
+
const value = element.getAttribute(attribute);
|
|
2771
|
+
if (!value?.startsWith("/media/")) continue;
|
|
2772
|
+
const resolved = previews.resolve(value);
|
|
2773
|
+
if (resolved !== value) {
|
|
2774
|
+
element.setAttribute(`data-gf-${attribute}`, value);
|
|
2775
|
+
element.setAttribute(attribute, resolved);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
const style = element.getAttribute("style");
|
|
2779
|
+
if (style?.includes("/media/")) {
|
|
2780
|
+
const resolved = style.replace(MEDIA_IN_STYLE, (_match, quote, url) => `url(${quote}${previews.resolve(url)}${quote})`);
|
|
2781
|
+
if (resolved !== style) element.setAttribute("style", resolved);
|
|
2782
|
+
}
|
|
2783
|
+
};
|
|
2784
|
+
const fixTree = (root) => {
|
|
2785
|
+
fix(root);
|
|
2786
|
+
for (const element of root.querySelectorAll("[src], [href], [poster], [style]")) fix(element);
|
|
2787
|
+
};
|
|
2788
|
+
const observer = new MutationObserver((records) => {
|
|
2789
|
+
for (const record of records) {
|
|
2790
|
+
if (record.type === "attributes" && record.target.nodeType === 1) fix(record.target);
|
|
2791
|
+
for (const node of record.addedNodes) if (node.nodeType === 1) fixTree(node);
|
|
2792
|
+
}
|
|
2793
|
+
});
|
|
2794
|
+
observer.observe(doc.documentElement, {
|
|
2795
|
+
subtree: true,
|
|
2796
|
+
childList: true,
|
|
2797
|
+
attributes: true,
|
|
2798
|
+
attributeFilter: [...URL_ATTRIBUTES, "style"]
|
|
2799
|
+
});
|
|
2800
|
+
fixTree(doc.documentElement);
|
|
2801
|
+
const onError = (event) => {
|
|
2802
|
+
const image = event.target;
|
|
2803
|
+
if (image?.tagName !== "IMG") return;
|
|
2804
|
+
const original = image.getAttribute("data-gf-src") ?? image.getAttribute("src") ?? "";
|
|
2805
|
+
if (!original.startsWith("/media/") || image.hasAttribute("data-gf-loaded")) return;
|
|
2806
|
+
image.setAttribute("data-gf-loaded", "");
|
|
2807
|
+
previews.load(original).then((url) => {
|
|
2808
|
+
if (url) {
|
|
2809
|
+
image.setAttribute("data-gf-src", original);
|
|
2810
|
+
image.setAttribute("src", url);
|
|
2811
|
+
}
|
|
2812
|
+
});
|
|
2813
|
+
};
|
|
2814
|
+
doc.addEventListener("error", onError, true);
|
|
2815
|
+
return () => {
|
|
2816
|
+
observer.disconnect();
|
|
2817
|
+
doc.removeEventListener("error", onError, true);
|
|
2818
|
+
};
|
|
2819
|
+
}
|
|
2820
|
+
//#endregion
|
|
2821
|
+
//#region src/registry-version.ts
|
|
2822
|
+
/** The release of Goodfellow's block registry this admin panel was built with, so a site gets blocks that match it. */
|
|
2823
|
+
const REGISTRY_VERSION = "0.5.0";
|
|
2824
|
+
//#endregion
|
|
2825
|
+
//#region src/router.tsx
|
|
2826
|
+
function parseHash(hash) {
|
|
2827
|
+
const [path = "", query = ""] = hash.replace(/^#\/?/, "").split("?");
|
|
2828
|
+
return {
|
|
2829
|
+
segments: path.split("/").filter(Boolean),
|
|
2830
|
+
params: new URLSearchParams(query)
|
|
2831
|
+
};
|
|
2832
|
+
}
|
|
2833
|
+
function subscribe$1(onChange) {
|
|
2834
|
+
window.addEventListener("hashchange", onChange);
|
|
2835
|
+
return () => window.removeEventListener("hashchange", onChange);
|
|
2836
|
+
}
|
|
2837
|
+
function useRoute() {
|
|
2838
|
+
return parseHash(useSyncExternalStore(subscribe$1, () => window.location.hash, () => ""));
|
|
2839
|
+
}
|
|
2840
|
+
const guards = /* @__PURE__ */ new Set();
|
|
2841
|
+
/** Whether any screen has unpublished changes. */
|
|
2842
|
+
function hasUnsavedChanges() {
|
|
2843
|
+
return [...guards].some((isDirty) => isDirty());
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Registers a screen's unpublished changes, so leaving it (inside the admin
|
|
2847
|
+
* panel or by closing the tab) asks for confirmation first.
|
|
2848
|
+
*/
|
|
2849
|
+
function useUnsavedChanges(isDirty) {
|
|
2850
|
+
useEffect(() => {
|
|
2851
|
+
if (!isDirty) return;
|
|
2852
|
+
const guard = () => true;
|
|
2853
|
+
guards.add(guard);
|
|
2854
|
+
const onBeforeUnload = (event) => event.preventDefault();
|
|
2855
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
2856
|
+
return () => {
|
|
2857
|
+
guards.delete(guard);
|
|
2858
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
2859
|
+
};
|
|
2860
|
+
}, [isDirty]);
|
|
2861
|
+
}
|
|
2862
|
+
/** Goes to another screen, asking first if there are unpublished changes. Returns whether it navigated. */
|
|
2863
|
+
function navigate(to, confirmLeave) {
|
|
2864
|
+
if (hasUnsavedChanges() && !confirmLeave()) return false;
|
|
2865
|
+
guards.clear();
|
|
2866
|
+
window.location.hash = to;
|
|
2867
|
+
return true;
|
|
2868
|
+
}
|
|
2869
|
+
/** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
|
|
2870
|
+
const SETTINGS_SECTIONS = {
|
|
2871
|
+
contact: "contact",
|
|
2872
|
+
forms: "forms"
|
|
2873
|
+
};
|
|
2874
|
+
function pageEditorHref(path) {
|
|
2875
|
+
return `#/pages/edit?path=${encodeURIComponent(path)}`;
|
|
2876
|
+
}
|
|
2877
|
+
/** An in-app link that asks before leaving a screen with unpublished changes. */
|
|
2878
|
+
function Link({ href, confirmLeave, ...props }) {
|
|
2879
|
+
return /* @__PURE__ */ jsx("a", {
|
|
2880
|
+
...props,
|
|
2881
|
+
href,
|
|
2882
|
+
onClick: (event) => {
|
|
2883
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;
|
|
2884
|
+
event.preventDefault();
|
|
2885
|
+
navigate(href, confirmLeave);
|
|
2886
|
+
}
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
function collectionHref(id, tab = "entries") {
|
|
2890
|
+
return tab === "entries" ? `#/collections/${id}` : `#/collections/${id}/${tab}`;
|
|
2891
|
+
}
|
|
2892
|
+
function entryEditorHref(collection, slug) {
|
|
2893
|
+
return `#/collections/${collection}/edit?slug=${encodeURIComponent(slug)}`;
|
|
2894
|
+
}
|
|
2895
|
+
//#endregion
|
|
2896
|
+
//#region src/schedule.tsx
|
|
2897
|
+
/** The computer's own time zone, which a site without one takes when something is first scheduled. */
|
|
2898
|
+
function computerTimeZone() {
|
|
2899
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
|
|
2900
|
+
}
|
|
2901
|
+
/** The time zone schedules are in: the site's, or the computer's, which `scheduleSettingsChanges()` gives it. */
|
|
2902
|
+
function scheduleTimeZone(content) {
|
|
2903
|
+
return content.settings.timeZone ?? computerTimeZone();
|
|
2904
|
+
}
|
|
2905
|
+
/**
|
|
2906
|
+
* The change to the site's settings a schedule needs, if any: the computer's
|
|
2907
|
+
* time zone, for a site without one, so times mean what the editor meant.
|
|
2908
|
+
*/
|
|
2909
|
+
function scheduleSettingsChanges(content, schedule) {
|
|
2910
|
+
const zone = computerTimeZone();
|
|
2911
|
+
if (content.settings.timeZone || !zone || !(schedule.showFrom || schedule.hideAfter)) return [];
|
|
2912
|
+
return [siteSettingsFileChange({
|
|
2913
|
+
...content.settings,
|
|
2914
|
+
timeZone: zone
|
|
2915
|
+
})];
|
|
2916
|
+
}
|
|
2917
|
+
/** A scheduled time as people read it, in the site's language: "29 Nov 2026, 09:00". */
|
|
2918
|
+
function formatScheduleTime(time, language) {
|
|
2919
|
+
const ms = wallClock(time);
|
|
2920
|
+
if (ms === void 0) return time;
|
|
2921
|
+
const options = {
|
|
2922
|
+
dateStyle: "medium",
|
|
2923
|
+
timeStyle: "short",
|
|
2924
|
+
timeZone: "UTC"
|
|
2925
|
+
};
|
|
2926
|
+
try {
|
|
2927
|
+
return new Intl.DateTimeFormat(language, options).format(new Date(ms));
|
|
2928
|
+
} catch {
|
|
2929
|
+
return new Intl.DateTimeFormat("en", options).format(new Date(ms));
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
const infoCache = /* @__PURE__ */ new WeakMap();
|
|
2933
|
+
/** When scheduled changes go live on this site, and who can read the repository, once the backend has said. */
|
|
2934
|
+
function useScheduleInfo() {
|
|
2935
|
+
const { scheduledBuilds, repositoryVisibility } = useAdmin();
|
|
2936
|
+
return useScheduleInfoOf(scheduledBuilds, repositoryVisibility);
|
|
2937
|
+
}
|
|
2938
|
+
/** `useScheduleInfo()` for a backend's own. */
|
|
2939
|
+
function useScheduleInfoOf(scheduledBuilds, repositoryVisibility) {
|
|
2940
|
+
const [info, setInfo] = useState({});
|
|
2941
|
+
useEffect(() => {
|
|
2942
|
+
if (!scheduledBuilds && !repositoryVisibility) return;
|
|
2943
|
+
const key = scheduledBuilds ?? repositoryVisibility;
|
|
2944
|
+
let found = infoCache.get(key);
|
|
2945
|
+
if (!found) {
|
|
2946
|
+
found = Promise.all([scheduledBuilds?.timing().catch(() => void 0), repositoryVisibility?.().catch(() => void 0)]).then(([timing, visibility]) => ({
|
|
2947
|
+
...timing && { timing },
|
|
2948
|
+
...visibility && { visibility }
|
|
2949
|
+
}));
|
|
2950
|
+
infoCache.set(key, found);
|
|
2951
|
+
}
|
|
2952
|
+
let cancelled = false;
|
|
2953
|
+
found.then((next) => {
|
|
2954
|
+
if (!cancelled) setInfo(next);
|
|
2955
|
+
});
|
|
2956
|
+
return () => {
|
|
2957
|
+
cancelled = true;
|
|
2958
|
+
};
|
|
2959
|
+
}, [scheduledBuilds, repositoryVisibility]);
|
|
2960
|
+
return info;
|
|
2961
|
+
}
|
|
2962
|
+
const TIMING = {
|
|
2963
|
+
exact: "schedule.timing.exact",
|
|
2964
|
+
hourly: "schedule.timing.hourly",
|
|
2965
|
+
nightly: "schedule.timing.nightly",
|
|
2966
|
+
none: "schedule.timing.none"
|
|
2967
|
+
};
|
|
2968
|
+
/** When scheduled changes go live, and, where the repository can be read by others, that scheduled content can be too. */
|
|
2969
|
+
function ScheduleNotes() {
|
|
2970
|
+
const t = useStrings();
|
|
2971
|
+
const { account, scheduleSyncProblem } = useAdmin();
|
|
2972
|
+
const { timing, visibility } = useScheduleInfo();
|
|
2973
|
+
const host = account?.hostName ?? "";
|
|
2974
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [timing && /* @__PURE__ */ jsx("p", {
|
|
2975
|
+
className: "gfa-hint",
|
|
2976
|
+
children: t(scheduleSyncProblem === "not-allowed" ? "schedule.timing.notAllowed" : TIMING[timing], { host })
|
|
2977
|
+
}), (visibility === "public" || visibility === "internal") && /* @__PURE__ */ jsx("p", {
|
|
2978
|
+
className: "gfa-notice gfa-notice-warning",
|
|
2979
|
+
role: "note",
|
|
2980
|
+
children: t(visibility === "public" ? "schedule.public" : "schedule.internal", { host })
|
|
2981
|
+
})] });
|
|
2982
|
+
}
|
|
2983
|
+
/** Two date and time fields, for when something is shown and hidden again, in the site's time zone. */
|
|
2984
|
+
function ScheduleFields({ value, onChange, content, showErrors = true }) {
|
|
2985
|
+
const t = useStrings();
|
|
2986
|
+
const problem = showErrors ? scheduleProblem(value) : void 0;
|
|
2987
|
+
const zone = scheduleTimeZone(content);
|
|
2988
|
+
const set = (key, time) => {
|
|
2989
|
+
const { [key]: _old, ...rest } = value;
|
|
2990
|
+
onChange(time ? {
|
|
2991
|
+
...rest,
|
|
2992
|
+
[key]: time
|
|
2993
|
+
} : rest);
|
|
2994
|
+
};
|
|
2995
|
+
const zoneHint = !zone ? t("schedule.zoneNone") : t(content.settings.timeZone ? "schedule.zone" : "schedule.zoneNew", { zone: zone.replace(/_/g, " ") });
|
|
2996
|
+
return /* @__PURE__ */ jsxs("fieldset", {
|
|
2997
|
+
className: "gfa-schedule",
|
|
2998
|
+
children: [
|
|
2999
|
+
/* @__PURE__ */ jsx("legend", {
|
|
3000
|
+
className: "gfa-label",
|
|
3001
|
+
children: t("schedule.title")
|
|
3002
|
+
}),
|
|
3003
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3004
|
+
className: "gfa-schedule-times",
|
|
3005
|
+
children: [/* @__PURE__ */ jsx(Field, {
|
|
3006
|
+
label: t("schedule.showFrom"),
|
|
3007
|
+
hint: t("schedule.showFromHint"),
|
|
3008
|
+
children: (props) => /* @__PURE__ */ jsx("input", {
|
|
3009
|
+
...props,
|
|
3010
|
+
type: "datetime-local",
|
|
3011
|
+
className: "gfa-input",
|
|
3012
|
+
value: value.showFrom ?? "",
|
|
3013
|
+
onChange: (event) => set("showFrom", event.target.value)
|
|
3014
|
+
})
|
|
3015
|
+
}), /* @__PURE__ */ jsx(Field, {
|
|
3016
|
+
label: t("schedule.hideAfter"),
|
|
3017
|
+
hint: t("schedule.hideAfterHint"),
|
|
3018
|
+
error: problem ? t(problem === "order" ? "schedule.order" : "schedule.invalid") : void 0,
|
|
3019
|
+
children: (props) => /* @__PURE__ */ jsx("input", {
|
|
3020
|
+
...props,
|
|
3021
|
+
type: "datetime-local",
|
|
3022
|
+
className: "gfa-input",
|
|
3023
|
+
value: value.hideAfter ?? "",
|
|
3024
|
+
onChange: (event) => set("hideAfter", event.target.value)
|
|
3025
|
+
})
|
|
3026
|
+
})]
|
|
3027
|
+
}),
|
|
3028
|
+
/* @__PURE__ */ jsx("p", {
|
|
3029
|
+
className: "gfa-hint",
|
|
3030
|
+
children: zoneHint
|
|
3031
|
+
}),
|
|
3032
|
+
(value.showFrom || value.hideAfter) && /* @__PURE__ */ jsx(ScheduleNotes, {})
|
|
3033
|
+
]
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
/** Whether a page or item is on the site now, for lists: nothing for one that's shown with nothing to come. */
|
|
3037
|
+
function ScheduleStatus({ schedule, content }) {
|
|
3038
|
+
const t = useStrings();
|
|
3039
|
+
const { language, timeZone } = content.settings;
|
|
3040
|
+
const state = scheduleState(schedule, /* @__PURE__ */ new Date(), timeZone);
|
|
3041
|
+
const time = (value) => formatScheduleTime(value, language);
|
|
3042
|
+
if (state === "waiting" && schedule.showFrom) return /* @__PURE__ */ jsx("span", {
|
|
3043
|
+
className: "gfa-schedule-status",
|
|
3044
|
+
children: t("schedule.status.waiting", { time: time(schedule.showFrom) })
|
|
3045
|
+
});
|
|
3046
|
+
if (state === "hidden" && schedule.hideAfter) return /* @__PURE__ */ jsx("span", {
|
|
3047
|
+
className: "gfa-schedule-status",
|
|
3048
|
+
children: t("schedule.status.hidden", { time: time(schedule.hideAfter) })
|
|
3049
|
+
});
|
|
3050
|
+
if (schedule.hideAfter) return /* @__PURE__ */ jsx("span", {
|
|
3051
|
+
className: "gfa-schedule-status",
|
|
3052
|
+
children: t("schedule.status.hides", { time: time(schedule.hideAfter) })
|
|
3053
|
+
});
|
|
3054
|
+
return null;
|
|
3055
|
+
}
|
|
3056
|
+
/** What the site's schedules show and hide next, soonest first, with when it goes live. */
|
|
3057
|
+
function ComingUp({ content }) {
|
|
3058
|
+
const t = useStrings();
|
|
3059
|
+
const upcoming = upcomingChanges(content);
|
|
3060
|
+
if (upcoming.length === 0) return null;
|
|
3061
|
+
const { language, timeZone } = content.settings;
|
|
3062
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
3063
|
+
className: "gfa-coming-up",
|
|
3064
|
+
"aria-labelledby": "gfa-coming-up-title",
|
|
3065
|
+
children: [
|
|
3066
|
+
/* @__PURE__ */ jsx("h2", {
|
|
3067
|
+
id: "gfa-coming-up-title",
|
|
3068
|
+
className: "gfa-section-title",
|
|
3069
|
+
children: t("comingUp.title")
|
|
3070
|
+
}),
|
|
3071
|
+
/* @__PURE__ */ jsx("ul", { children: upcoming.map((change) => {
|
|
3072
|
+
const href = change.entry ? entryEditorHref(change.entry.collection, change.entry.slug) : change.path ? pageEditorHref(change.path) : void 0;
|
|
3073
|
+
const collection = change.entry && content.collections.find((item) => item.id === change.entry?.collection);
|
|
3074
|
+
return /* @__PURE__ */ jsxs("li", { children: [
|
|
3075
|
+
/* @__PURE__ */ jsx("time", {
|
|
3076
|
+
dateTime: change.at.toISOString(),
|
|
3077
|
+
children: formatScheduleTime(change.time, language)
|
|
3078
|
+
}),
|
|
3079
|
+
/* @__PURE__ */ jsx("span", {
|
|
3080
|
+
className: "gfa-coming-up-kind",
|
|
3081
|
+
children: t(change.kind === "show" ? "comingUp.show" : "comingUp.hide")
|
|
3082
|
+
}),
|
|
3083
|
+
href ? /* @__PURE__ */ jsx("a", {
|
|
3084
|
+
href,
|
|
3085
|
+
children: change.title
|
|
3086
|
+
}) : change.title,
|
|
3087
|
+
collection && /* @__PURE__ */ jsx("span", {
|
|
3088
|
+
className: "gfa-hint",
|
|
3089
|
+
children: collection.settings.name
|
|
3090
|
+
})
|
|
3091
|
+
] }, `${change.file} ${change.kind}`);
|
|
3092
|
+
}) }),
|
|
3093
|
+
/* @__PURE__ */ jsx("p", {
|
|
3094
|
+
className: "gfa-hint",
|
|
3095
|
+
children: t("comingUp.zone", { zone: (timeZone ?? "UTC").replace(/_/g, " ") })
|
|
3096
|
+
}),
|
|
3097
|
+
/* @__PURE__ */ jsx(ScheduleNotes, {})
|
|
3098
|
+
]
|
|
3099
|
+
});
|
|
3100
|
+
}
|
|
3101
|
+
//#endregion
|
|
3102
|
+
//#region src/site-icon.ts
|
|
3103
|
+
/**
|
|
3104
|
+
* Shows the site's icon in the browser tab, as the site's own pages do: the
|
|
3105
|
+
* live site's copy, or, for an icon the live site doesn't have yet, the
|
|
3106
|
+
* repository's. Returns a function that stops waiting for it.
|
|
3107
|
+
*/
|
|
3108
|
+
function showSiteIcon(doc, favicon, previews) {
|
|
3109
|
+
let stopped = false;
|
|
3110
|
+
const set = (href) => {
|
|
3111
|
+
if (stopped) return;
|
|
3112
|
+
let link = doc.head.querySelector("link[rel~=\"icon\"]");
|
|
3113
|
+
if (!link) {
|
|
3114
|
+
link = doc.createElement("link");
|
|
3115
|
+
link.rel = "icon";
|
|
3116
|
+
doc.head.append(link);
|
|
3117
|
+
}
|
|
3118
|
+
link.href = href;
|
|
3119
|
+
};
|
|
3120
|
+
const live = previews.resolve(favicon);
|
|
3121
|
+
const probe = new Image();
|
|
3122
|
+
probe.onload = () => set(live);
|
|
3123
|
+
probe.onerror = () => void previews.load(favicon).then((loaded) => loaded && set(loaded));
|
|
3124
|
+
probe.src = live;
|
|
3125
|
+
return () => {
|
|
3126
|
+
stopped = true;
|
|
3127
|
+
probe.onload = probe.onerror = null;
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
//#endregion
|
|
3131
|
+
//#region src/theme.ts
|
|
3132
|
+
const KEY = "goodfellow-admin-theme";
|
|
3133
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
3134
|
+
function read() {
|
|
3135
|
+
try {
|
|
3136
|
+
const saved = localStorage.getItem(KEY);
|
|
3137
|
+
return saved === "light" || saved === "dark" ? saved : "system";
|
|
3138
|
+
} catch {
|
|
3139
|
+
return "system";
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
let current;
|
|
3143
|
+
function choice() {
|
|
3144
|
+
current ??= read();
|
|
3145
|
+
return current;
|
|
3146
|
+
}
|
|
3147
|
+
/** Shows the choice: the stylesheet's colors follow `data-gfa-theme`, or the computer's setting without it. */
|
|
3148
|
+
function apply(next) {
|
|
3149
|
+
const root = document.documentElement;
|
|
3150
|
+
if (next === "system") delete root.dataset.gfaTheme;
|
|
3151
|
+
else root.dataset.gfaTheme = next;
|
|
3152
|
+
}
|
|
3153
|
+
function setThemeChoice(next) {
|
|
3154
|
+
current = next;
|
|
3155
|
+
try {
|
|
3156
|
+
if (next === "system") localStorage.removeItem(KEY);
|
|
3157
|
+
else localStorage.setItem(KEY, next);
|
|
3158
|
+
} catch {}
|
|
3159
|
+
apply(next);
|
|
3160
|
+
for (const listener of listeners) listener();
|
|
3161
|
+
}
|
|
3162
|
+
function subscribe(listener) {
|
|
3163
|
+
listeners.add(listener);
|
|
3164
|
+
const media = matchMedia("(prefers-color-scheme: dark)");
|
|
3165
|
+
media.addEventListener("change", listener);
|
|
3166
|
+
return () => {
|
|
3167
|
+
listeners.delete(listener);
|
|
3168
|
+
media.removeEventListener("change", listener);
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
3171
|
+
/** The choice, kept in this browser. */
|
|
3172
|
+
function useThemeChoice() {
|
|
3173
|
+
return useSyncExternalStore(subscribe, choice, () => "system");
|
|
3174
|
+
}
|
|
3175
|
+
/** Applies the choice to the page while the admin panel is open. Used once, by the admin panel itself. */
|
|
3176
|
+
function useApplyThemeChoice() {
|
|
3177
|
+
const value = useThemeChoice();
|
|
3178
|
+
useEffect(() => {
|
|
3179
|
+
apply(value);
|
|
3180
|
+
return () => {
|
|
3181
|
+
delete document.documentElement.dataset.gfaTheme;
|
|
3182
|
+
};
|
|
3183
|
+
}, [value]);
|
|
3184
|
+
}
|
|
3185
|
+
/** Whether the admin panel is dark at the moment, by choice or by the computer's setting. */
|
|
3186
|
+
function useDarkMode() {
|
|
3187
|
+
return useSyncExternalStore(subscribe, () => {
|
|
3188
|
+
const value = choice();
|
|
3189
|
+
return value === "dark" || value === "system" && matchMedia("(prefers-color-scheme: dark)").matches;
|
|
3190
|
+
}, () => false);
|
|
3191
|
+
}
|
|
3192
|
+
//#endregion
|
|
3193
|
+
//#region src/admin-context.tsx
|
|
3194
|
+
/** Whether the admin panel runs at the site's admin address, once it's checked (`AdminAddress.checked`). */
|
|
3195
|
+
function atAdminAddress(admin) {
|
|
3196
|
+
return admin?.checked === true && typeof location !== "undefined" && location.origin === adminOrigin(admin);
|
|
3197
|
+
}
|
|
3198
|
+
/** Provided by `AdminProvider`, and in the site's frames by a stand-in with only what their components use. */
|
|
3199
|
+
const AdminContext = createContext(null);
|
|
3200
|
+
/** How long a publish waits for the frame running the site's code before checking without it. */
|
|
3201
|
+
const CODE_TIMEOUT = 2e4;
|
|
3202
|
+
const DEPLOY_CHECK_INTERVAL = 5e3;
|
|
3203
|
+
const DEPLOY_CHECK_LIMIT = 12e5;
|
|
3204
|
+
async function load(store) {
|
|
3205
|
+
try {
|
|
3206
|
+
const revision = await store.revision();
|
|
3207
|
+
const [content, media] = await Promise.all([loadSiteContent(store), store.list(MEDIA_DIR)]);
|
|
3208
|
+
return {
|
|
3209
|
+
status: "ready",
|
|
3210
|
+
content,
|
|
3211
|
+
revision,
|
|
3212
|
+
media
|
|
3213
|
+
};
|
|
3214
|
+
} catch (error) {
|
|
3215
|
+
return {
|
|
3216
|
+
status: "error",
|
|
3217
|
+
error
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
function isGitBackend(store) {
|
|
3222
|
+
return "deployStatus" in store && typeof store.deployStatus === "function";
|
|
3223
|
+
}
|
|
3224
|
+
/** Tells apart sites served from the same address, for what this browser keeps about each. */
|
|
3225
|
+
function siteKey(siteUrl) {
|
|
3226
|
+
return typeof location === "undefined" ? siteUrl : new URL(siteUrl, location.href).href;
|
|
3227
|
+
}
|
|
3228
|
+
function AdminProvider({ site, frames, store, account, preview, siteUrl, registry, onSignInError, children }) {
|
|
3229
|
+
const t = useStrings();
|
|
3230
|
+
const [state, setState] = useState({ status: "loading" });
|
|
3231
|
+
const [deploy, setDeploy] = useState();
|
|
3232
|
+
const key = siteKey(siteUrl);
|
|
3233
|
+
const backendDrafts = isGitBackend(store) ? store.drafts : void 0;
|
|
3234
|
+
const siteDrafts = useMemo(() => backendDrafts ?? keptDrafts(store, browserDraftKeeper(key)), [
|
|
3235
|
+
backendDrafts,
|
|
3236
|
+
store,
|
|
3237
|
+
key
|
|
3238
|
+
]);
|
|
3239
|
+
const [draft, setDraft] = useState();
|
|
3240
|
+
const [draftFound, setDraftFound] = useState(false);
|
|
3241
|
+
const [draftNames, setDraftNames] = useState([]);
|
|
3242
|
+
const [draftPaths, setDraftPaths] = useState([]);
|
|
3243
|
+
const content = draft?.store ?? store;
|
|
3244
|
+
const [checking, setChecking] = useState();
|
|
3245
|
+
const blocks = useMemo(() => describedConfig(site.description, "template").components, [site.description]);
|
|
3246
|
+
const mediaPreviews = useMemo(() => createMediaPreviews(content, siteUrl), [content, siteUrl]);
|
|
3247
|
+
const outOfReach = registry !== void 0 || atAdminAddress(site.admin) || site.broker !== void 0;
|
|
3248
|
+
const registries = useMemo(() => ({
|
|
3249
|
+
[GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.5.0"),
|
|
3250
|
+
...outOfReach && {
|
|
3251
|
+
[SHADCN_NAMESPACE]: SHADCN_REGISTRY,
|
|
3252
|
+
...site.registries
|
|
3253
|
+
}
|
|
3254
|
+
}), [
|
|
3255
|
+
registry,
|
|
3256
|
+
site.registries,
|
|
3257
|
+
outOfReach
|
|
3258
|
+
]);
|
|
3259
|
+
const codeFrame = useRef({ waiting: [] });
|
|
3260
|
+
const onCodeClient = useCallback((client) => {
|
|
3261
|
+
codeFrame.current.client = client;
|
|
3262
|
+
if (client) for (const resolve of codeFrame.current.waiting.splice(0)) resolve(client);
|
|
3263
|
+
}, []);
|
|
3264
|
+
const code = useMemo(() => frameSiteCode(() => {
|
|
3265
|
+
const { client, waiting } = codeFrame.current;
|
|
3266
|
+
if (client) return Promise.resolve(client);
|
|
3267
|
+
return new Promise((resolve, reject) => {
|
|
3268
|
+
waiting.push(resolve);
|
|
3269
|
+
setTimeout(() => reject(/* @__PURE__ */ new Error("The site's code didn't start.")), CODE_TIMEOUT);
|
|
3270
|
+
});
|
|
3271
|
+
}), []);
|
|
3272
|
+
const favicon = state.status === "ready" ? state.content.settings.favicon : void 0;
|
|
3273
|
+
useEffect(() => favicon ? showSiteIcon(document, favicon, mediaPreviews) : void 0, [favicon, mediaPreviews]);
|
|
3274
|
+
const readFile = useCallback((path) => content.read(path), [content]);
|
|
3275
|
+
const listFiles = useCallback((dir) => content.list(dir), [content]);
|
|
3276
|
+
const demo = isDemoStore(store) ? store : void 0;
|
|
3277
|
+
const pages = isGitBackend(store) ? store.pages : void 0;
|
|
3278
|
+
const versions = isGitBackend(store) ? store : void 0;
|
|
3279
|
+
const changedPaths = useMemo(() => isGitBackend(store) ? (from, to) => store.changedPaths(from, to) : void 0, [store]);
|
|
3280
|
+
const editors = isGitBackend(store) ? store.editors : void 0;
|
|
3281
|
+
const updates = isGitBackend(store) ? store.updates : void 0;
|
|
3282
|
+
const contentOnly = isGitBackend(store) && store.contentOnly === true;
|
|
3283
|
+
const scheduledBuilds = isGitBackend(store) ? store.scheduledBuilds : void 0;
|
|
3284
|
+
const repositoryVisibility = useMemo(() => isGitBackend(store) ? store.repositoryVisibility?.bind(store) : void 0, [store]);
|
|
3285
|
+
const [scheduleSyncProblem, setScheduleSyncProblem] = useState();
|
|
3286
|
+
const backendOwner = isGitBackend(store) ? store.ownerAccess : void 0;
|
|
3287
|
+
const [ownerActive, setOwnerActive] = useState(backendOwner?.active ?? false);
|
|
3288
|
+
const ownerAccess = useMemo(() => backendOwner && {
|
|
3289
|
+
tokenLink: backendOwner.tokenLink,
|
|
3290
|
+
active: ownerActive,
|
|
3291
|
+
problem: backendOwner.problem,
|
|
3292
|
+
...backendOwner.startRedirect && { startRedirect: (options) => backendOwner.startRedirect?.(options) ?? Promise.resolve() },
|
|
3293
|
+
async applyToken(token) {
|
|
3294
|
+
await backendOwner.applyToken(token);
|
|
3295
|
+
setOwnerActive(true);
|
|
3296
|
+
},
|
|
3297
|
+
forget() {
|
|
3298
|
+
backendOwner.forget();
|
|
3299
|
+
setOwnerActive(false);
|
|
3300
|
+
}
|
|
3301
|
+
}, [backendOwner, ownerActive]);
|
|
3302
|
+
useEffect(() => {
|
|
3303
|
+
if (!demo) return;
|
|
3304
|
+
demo.changes().then((changes) => {
|
|
3305
|
+
for (const change of changes) if ("bytes" in change && change.path.startsWith(`${MEDIA_DIR}/`)) mediaPreviews.remember(mediaUrl(change.path), change.bytes);
|
|
3306
|
+
});
|
|
3307
|
+
}, [demo, mediaPreviews]);
|
|
3308
|
+
const loadedFrom = useRef(void 0);
|
|
3309
|
+
const views = useRef(0);
|
|
3310
|
+
const loadFrom = useCallback(async (from) => {
|
|
3311
|
+
loadedFrom.current = from;
|
|
3312
|
+
const view = views.current;
|
|
3313
|
+
const next = await load(from);
|
|
3314
|
+
if (next.status === "error" && next.error instanceof SignInError) onSignInError?.(next.error);
|
|
3315
|
+
if (loadedFrom.current === from) setState(next.status === "ready" ? {
|
|
3316
|
+
...next,
|
|
3317
|
+
view
|
|
3318
|
+
} : next);
|
|
3319
|
+
}, [onSignInError]);
|
|
3320
|
+
const reload = useCallback(() => loadFrom(content), [loadFrom, content]);
|
|
3321
|
+
useEffect(() => {
|
|
3322
|
+
let cancelled = false;
|
|
3323
|
+
(async () => {
|
|
3324
|
+
const names = await siteDrafts.list().catch(() => []);
|
|
3325
|
+
const remembered = rememberedDraft(key);
|
|
3326
|
+
const opened = remembered && names.includes(remembered) ? await siteDrafts.open(remembered).catch(() => void 0) : void 0;
|
|
3327
|
+
if (!opened) rememberDraft(key, void 0);
|
|
3328
|
+
if (cancelled) return;
|
|
3329
|
+
setDraftNames(names);
|
|
3330
|
+
setDraft(opened);
|
|
3331
|
+
setDraftFound(true);
|
|
3332
|
+
})();
|
|
3333
|
+
return () => {
|
|
3334
|
+
cancelled = true;
|
|
3335
|
+
};
|
|
3336
|
+
}, [siteDrafts, key]);
|
|
3337
|
+
useEffect(() => {
|
|
3338
|
+
if (draftFound && loadedFrom.current !== content) reload();
|
|
3339
|
+
}, [
|
|
3340
|
+
draftFound,
|
|
3341
|
+
content,
|
|
3342
|
+
reload
|
|
3343
|
+
]);
|
|
3344
|
+
const refreshDrafts = useCallback(async (current) => {
|
|
3345
|
+
const [names, paths] = await Promise.all([siteDrafts.list().catch(() => []), current ? current.paths().catch(() => []) : []]);
|
|
3346
|
+
setDraftNames(names);
|
|
3347
|
+
setDraftPaths(paths);
|
|
3348
|
+
}, [siteDrafts]);
|
|
3349
|
+
useEffect(() => {
|
|
3350
|
+
if (draftFound) refreshDrafts(draft);
|
|
3351
|
+
}, [
|
|
3352
|
+
draftFound,
|
|
3353
|
+
draft,
|
|
3354
|
+
refreshDrafts
|
|
3355
|
+
]);
|
|
3356
|
+
/** Shows the site checks' dialog where they found anything, and says whether to go ahead. */
|
|
3357
|
+
const confirmChecks = useCallback(async (checks) => {
|
|
3358
|
+
if (!checks || checks.blocking.length === 0 && checks.warnings.length === 0) return true;
|
|
3359
|
+
const go = await new Promise((decide) => setChecking({
|
|
3360
|
+
checks,
|
|
3361
|
+
content: checks.content,
|
|
3362
|
+
decide
|
|
3363
|
+
}));
|
|
3364
|
+
setChecking(void 0);
|
|
3365
|
+
return go;
|
|
3366
|
+
}, []);
|
|
3367
|
+
const publish = useCallback(async (changes, message, options) => {
|
|
3368
|
+
if (state.status !== "ready") return {
|
|
3369
|
+
ok: false,
|
|
3370
|
+
reason: "error",
|
|
3371
|
+
error: /* @__PURE__ */ new Error("The site isn't loaded.")
|
|
3372
|
+
};
|
|
3373
|
+
const checks = options?.checks === false ? void 0 : await checkPublish(content, state, changes, {
|
|
3374
|
+
blocks,
|
|
3375
|
+
code
|
|
3376
|
+
});
|
|
3377
|
+
if (!await confirmChecks(checks)) return {
|
|
3378
|
+
ok: false,
|
|
3379
|
+
reason: "checks",
|
|
3380
|
+
error: void 0
|
|
3381
|
+
};
|
|
3382
|
+
let target = draft;
|
|
3383
|
+
let started;
|
|
3384
|
+
let revision;
|
|
3385
|
+
try {
|
|
3386
|
+
let expectedRevision = state.revision;
|
|
3387
|
+
if (!target && options?.draft) {
|
|
3388
|
+
target = started = await siteDrafts.create(newDraftName(), { from: state.revision });
|
|
3389
|
+
expectedRevision = await started.store.revision();
|
|
3390
|
+
}
|
|
3391
|
+
({revision} = await writeChanges(target?.store ?? store, changes, {
|
|
3392
|
+
message,
|
|
3393
|
+
expectedRevision
|
|
3394
|
+
}));
|
|
3395
|
+
} catch (error) {
|
|
3396
|
+
if (started) await started.discard().catch(() => void 0);
|
|
3397
|
+
if (error instanceof SignInError && error.problem === "invalid") onSignInError?.(error);
|
|
3398
|
+
return {
|
|
3399
|
+
ok: false,
|
|
3400
|
+
reason: error instanceof ConflictError ? "conflict" : "error",
|
|
3401
|
+
error
|
|
3402
|
+
};
|
|
3403
|
+
}
|
|
3404
|
+
if (started) {
|
|
3405
|
+
rememberDraft(key, started.name);
|
|
3406
|
+
setDraft(started);
|
|
3407
|
+
} else if (target) refreshDrafts(target);
|
|
3408
|
+
else if (isGitBackend(store)) setDeploy({
|
|
3409
|
+
revision,
|
|
3410
|
+
previous: state.revision,
|
|
3411
|
+
state: "building"
|
|
3412
|
+
});
|
|
3413
|
+
await loadFrom(target?.store ?? store);
|
|
3414
|
+
return { ok: true };
|
|
3415
|
+
}, [
|
|
3416
|
+
store,
|
|
3417
|
+
content,
|
|
3418
|
+
draft,
|
|
3419
|
+
siteDrafts,
|
|
3420
|
+
key,
|
|
3421
|
+
state,
|
|
3422
|
+
blocks,
|
|
3423
|
+
code,
|
|
3424
|
+
confirmChecks,
|
|
3425
|
+
refreshDrafts,
|
|
3426
|
+
loadFrom,
|
|
3427
|
+
onSignInError
|
|
3428
|
+
]);
|
|
3429
|
+
const openDraft = useCallback(async (name) => {
|
|
3430
|
+
if (name === draft?.name) return true;
|
|
3431
|
+
let next;
|
|
3432
|
+
try {
|
|
3433
|
+
next = name ? await siteDrafts.open(name) : void 0;
|
|
3434
|
+
} catch (error) {
|
|
3435
|
+
if (!(error instanceof DraftError)) throw error;
|
|
3436
|
+
refreshDrafts(draft);
|
|
3437
|
+
return false;
|
|
3438
|
+
}
|
|
3439
|
+
rememberDraft(key, name);
|
|
3440
|
+
views.current++;
|
|
3441
|
+
setDraft(next);
|
|
3442
|
+
return true;
|
|
3443
|
+
}, [
|
|
3444
|
+
draft,
|
|
3445
|
+
siteDrafts,
|
|
3446
|
+
key,
|
|
3447
|
+
refreshDrafts
|
|
3448
|
+
]);
|
|
3449
|
+
const publishAll = useCallback(async () => {
|
|
3450
|
+
if (!draft) return {
|
|
3451
|
+
ok: false,
|
|
3452
|
+
reason: "error",
|
|
3453
|
+
error: /* @__PURE__ */ new Error("There's no draft open.")
|
|
3454
|
+
};
|
|
3455
|
+
let changes;
|
|
3456
|
+
let live;
|
|
3457
|
+
try {
|
|
3458
|
+
[changes, live] = await Promise.all([draft.changes(), load(store)]);
|
|
3459
|
+
} catch (error) {
|
|
3460
|
+
return {
|
|
3461
|
+
ok: false,
|
|
3462
|
+
reason: "error",
|
|
3463
|
+
error
|
|
3464
|
+
};
|
|
3465
|
+
}
|
|
3466
|
+
if (live.status !== "ready") return {
|
|
3467
|
+
ok: false,
|
|
3468
|
+
reason: "error",
|
|
3469
|
+
error: live.status === "error" && live.error
|
|
3470
|
+
};
|
|
3471
|
+
if (!await confirmChecks(await checkPublish(store, live, changes, {
|
|
3472
|
+
blocks,
|
|
3473
|
+
code
|
|
3474
|
+
}))) return {
|
|
3475
|
+
ok: false,
|
|
3476
|
+
reason: "checks",
|
|
3477
|
+
error: void 0
|
|
3478
|
+
};
|
|
3479
|
+
let revision;
|
|
3480
|
+
try {
|
|
3481
|
+
({revision} = await draft.publish(t("draft.message", { name: draft.name })));
|
|
3482
|
+
} catch (error) {
|
|
3483
|
+
if (error instanceof SignInError && error.problem === "invalid") onSignInError?.(error);
|
|
3484
|
+
return {
|
|
3485
|
+
ok: false,
|
|
3486
|
+
reason: error instanceof ConflictError ? "conflict" : "error",
|
|
3487
|
+
error
|
|
3488
|
+
};
|
|
3489
|
+
}
|
|
3490
|
+
rememberDraft(key, void 0);
|
|
3491
|
+
if (isGitBackend(store)) setDeploy({
|
|
3492
|
+
revision,
|
|
3493
|
+
previous: live.revision,
|
|
3494
|
+
state: "building"
|
|
3495
|
+
});
|
|
3496
|
+
views.current++;
|
|
3497
|
+
setDraft(void 0);
|
|
3498
|
+
return { ok: true };
|
|
3499
|
+
}, [
|
|
3500
|
+
draft,
|
|
3501
|
+
store,
|
|
3502
|
+
blocks,
|
|
3503
|
+
code,
|
|
3504
|
+
key,
|
|
3505
|
+
t,
|
|
3506
|
+
confirmChecks,
|
|
3507
|
+
onSignInError
|
|
3508
|
+
]);
|
|
3509
|
+
const submitDraft = useCallback(async () => {
|
|
3510
|
+
if (!draft) return {
|
|
3511
|
+
ok: false,
|
|
3512
|
+
error: /* @__PURE__ */ new Error("There's no draft open.")
|
|
3513
|
+
};
|
|
3514
|
+
try {
|
|
3515
|
+
const paths = await draft.paths();
|
|
3516
|
+
const { url } = await draft.submitForReview({
|
|
3517
|
+
title: t("draft.reviewTitle", { name: draft.name }),
|
|
3518
|
+
description: [
|
|
3519
|
+
t("draft.reviewDescription"),
|
|
3520
|
+
"",
|
|
3521
|
+
...paths.map((path) => `- \`${path}\``)
|
|
3522
|
+
].join("\n")
|
|
3523
|
+
});
|
|
3524
|
+
return {
|
|
3525
|
+
ok: true,
|
|
3526
|
+
url
|
|
3527
|
+
};
|
|
3528
|
+
} catch (error) {
|
|
3529
|
+
return {
|
|
3530
|
+
ok: false,
|
|
3531
|
+
error
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3534
|
+
}, [draft, t]);
|
|
3535
|
+
const discardDraft = useCallback(async () => {
|
|
3536
|
+
if (!draft) return { ok: true };
|
|
3537
|
+
try {
|
|
3538
|
+
await draft.discard();
|
|
3539
|
+
} catch (error) {
|
|
3540
|
+
return {
|
|
3541
|
+
ok: false,
|
|
3542
|
+
error
|
|
3543
|
+
};
|
|
3544
|
+
}
|
|
3545
|
+
rememberDraft(key, void 0);
|
|
3546
|
+
views.current++;
|
|
3547
|
+
setDraft(void 0);
|
|
3548
|
+
return { ok: true };
|
|
3549
|
+
}, [draft, key]);
|
|
3550
|
+
const drafts = useMemo(() => ({
|
|
3551
|
+
kept: backendDrafts === void 0,
|
|
3552
|
+
names: draftNames,
|
|
3553
|
+
...draft && { current: {
|
|
3554
|
+
name: draft.name,
|
|
3555
|
+
paths: draftPaths
|
|
3556
|
+
} },
|
|
3557
|
+
open: openDraft,
|
|
3558
|
+
publishAll,
|
|
3559
|
+
submit: submitDraft,
|
|
3560
|
+
discard: discardDraft
|
|
3561
|
+
}), [
|
|
3562
|
+
backendDrafts,
|
|
3563
|
+
draftNames,
|
|
3564
|
+
draft,
|
|
3565
|
+
draftPaths,
|
|
3566
|
+
openDraft,
|
|
3567
|
+
publishAll,
|
|
3568
|
+
submitDraft,
|
|
3569
|
+
discardDraft
|
|
3570
|
+
]);
|
|
3571
|
+
const syncedTimes = useRef(void 0);
|
|
3572
|
+
useEffect(() => {
|
|
3573
|
+
const sync = scheduledBuilds?.sync?.bind(scheduledBuilds);
|
|
3574
|
+
if (!sync || state.status !== "ready") return;
|
|
3575
|
+
const times = [...new Set(upcomingChanges(state.content).map((change) => change.at.getTime()))];
|
|
3576
|
+
const key = times.join(" ");
|
|
3577
|
+
if (key === syncedTimes.current || syncedTimes.current === void 0 && times.length === 0) return;
|
|
3578
|
+
syncedTimes.current = key;
|
|
3579
|
+
sync(times.map((time) => new Date(time))).then(() => setScheduleSyncProblem(void 0), (error) => {
|
|
3580
|
+
if (error instanceof ScheduledBuildsError) setScheduleSyncProblem(error.problem);
|
|
3581
|
+
else syncedTimes.current = void 0;
|
|
3582
|
+
});
|
|
3583
|
+
}, [scheduledBuilds, state]);
|
|
3584
|
+
useEffect(() => {
|
|
3585
|
+
if (deploy?.state !== "building" || !isGitBackend(store)) return;
|
|
3586
|
+
const started = Date.now();
|
|
3587
|
+
let cancelled = false;
|
|
3588
|
+
let timer;
|
|
3589
|
+
const check = async () => {
|
|
3590
|
+
const status = await store.deployStatus(deploy.revision).catch(() => ({ state: "unknown" }));
|
|
3591
|
+
if (cancelled) return;
|
|
3592
|
+
if (status.state === "building" && Date.now() - started < DEPLOY_CHECK_LIMIT) timer = setTimeout(check, DEPLOY_CHECK_INTERVAL);
|
|
3593
|
+
else setDeploy({
|
|
3594
|
+
revision: deploy.revision,
|
|
3595
|
+
previous: deploy.previous,
|
|
3596
|
+
...status
|
|
3597
|
+
});
|
|
3598
|
+
};
|
|
3599
|
+
timer = setTimeout(check, DEPLOY_CHECK_INTERVAL);
|
|
3600
|
+
return () => {
|
|
3601
|
+
cancelled = true;
|
|
3602
|
+
clearTimeout(timer);
|
|
3603
|
+
};
|
|
3604
|
+
}, [deploy, store]);
|
|
3605
|
+
const followBuild = useCallback(() => {
|
|
3606
|
+
if (state.status === "ready" && isGitBackend(store)) setDeploy({
|
|
3607
|
+
revision: state.revision,
|
|
3608
|
+
previous: state.revision,
|
|
3609
|
+
state: "building"
|
|
3610
|
+
});
|
|
3611
|
+
}, [state, store]);
|
|
3612
|
+
const value = useMemo(() => ({
|
|
3613
|
+
site,
|
|
3614
|
+
blocks,
|
|
3615
|
+
code,
|
|
3616
|
+
preview,
|
|
3617
|
+
siteUrl,
|
|
3618
|
+
state,
|
|
3619
|
+
account,
|
|
3620
|
+
deploy,
|
|
3621
|
+
mediaPreviews,
|
|
3622
|
+
reload,
|
|
3623
|
+
publish,
|
|
3624
|
+
drafts,
|
|
3625
|
+
readFile,
|
|
3626
|
+
listFiles,
|
|
3627
|
+
registries,
|
|
3628
|
+
outOfReach,
|
|
3629
|
+
demo,
|
|
3630
|
+
pages,
|
|
3631
|
+
versions,
|
|
3632
|
+
changedPaths,
|
|
3633
|
+
editors,
|
|
3634
|
+
ownerAccess,
|
|
3635
|
+
updates,
|
|
3636
|
+
contentOnly,
|
|
3637
|
+
scheduledBuilds,
|
|
3638
|
+
repositoryVisibility,
|
|
3639
|
+
scheduleSyncProblem,
|
|
3640
|
+
followBuild
|
|
3641
|
+
}), [
|
|
3642
|
+
site,
|
|
3643
|
+
blocks,
|
|
3644
|
+
code,
|
|
3645
|
+
preview,
|
|
3646
|
+
siteUrl,
|
|
3647
|
+
state,
|
|
3648
|
+
account,
|
|
3649
|
+
deploy,
|
|
3650
|
+
mediaPreviews,
|
|
3651
|
+
reload,
|
|
3652
|
+
publish,
|
|
3653
|
+
drafts,
|
|
3654
|
+
readFile,
|
|
3655
|
+
listFiles,
|
|
3656
|
+
registries,
|
|
3657
|
+
outOfReach,
|
|
3658
|
+
demo,
|
|
3659
|
+
pages,
|
|
3660
|
+
versions,
|
|
3661
|
+
changedPaths,
|
|
3662
|
+
editors,
|
|
3663
|
+
ownerAccess,
|
|
3664
|
+
updates,
|
|
3665
|
+
contentOnly,
|
|
3666
|
+
scheduledBuilds,
|
|
3667
|
+
repositoryVisibility,
|
|
3668
|
+
scheduleSyncProblem,
|
|
3669
|
+
followBuild
|
|
3670
|
+
]);
|
|
3671
|
+
const table = useStringTable();
|
|
3672
|
+
const strings = useMemo(() => draft ? {
|
|
3673
|
+
...table,
|
|
3674
|
+
"publish.button": table["draft.add"],
|
|
3675
|
+
"publish.publishing": table["draft.adding"],
|
|
3676
|
+
"publish.done": table["draft.added"]
|
|
3677
|
+
} : table, [draft, table]);
|
|
3678
|
+
return /* @__PURE__ */ jsx(AdminContext.Provider, {
|
|
3679
|
+
value,
|
|
3680
|
+
children: /* @__PURE__ */ jsxs(StringsProvider, {
|
|
3681
|
+
strings,
|
|
3682
|
+
children: [state.status === "ready" ? /* @__PURE__ */ jsx(SiteFrames, {
|
|
3683
|
+
frames,
|
|
3684
|
+
state,
|
|
3685
|
+
onCodeClient,
|
|
3686
|
+
children
|
|
3687
|
+
}) : children, checking && /* @__PURE__ */ jsx(ChecksDialog, {
|
|
3688
|
+
checks: checking.checks,
|
|
3689
|
+
content: checking.content,
|
|
3690
|
+
onDecide: checking.decide
|
|
3691
|
+
})]
|
|
3692
|
+
})
|
|
3693
|
+
});
|
|
3694
|
+
}
|
|
3695
|
+
/** The site's compiled CSS as text, read once, for the editor's frame, which can't read anything itself. */
|
|
3696
|
+
function useSiteCss(stylesheets) {
|
|
3697
|
+
const key = stylesheets.join("\n");
|
|
3698
|
+
return useMemo(() => {
|
|
3699
|
+
let found;
|
|
3700
|
+
return () => {
|
|
3701
|
+
found ??= Promise.all(key.split("\n").filter(Boolean).map((href) => fetch(href).then((response) => response.ok ? response.text() : "").catch(() => "")));
|
|
3702
|
+
return found;
|
|
3703
|
+
};
|
|
3704
|
+
}, [key]);
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* What the site's frames are told and can ask for, once the site has loaded,
|
|
3708
|
+
* and the hidden frame that runs the site's code for the screens.
|
|
3709
|
+
*/
|
|
3710
|
+
function SiteFrames({ frames, state, onCodeClient, children }) {
|
|
3711
|
+
const t = useStrings();
|
|
3712
|
+
const strings = useStringTable();
|
|
3713
|
+
const theme = useThemeChoice();
|
|
3714
|
+
const { preview, siteUrl, mediaPreviews, account, scheduledBuilds, repositoryVisibility, scheduleSyncProblem } = useAdmin();
|
|
3715
|
+
const { timing, visibility } = useScheduleInfoOf(scheduledBuilds, repositoryVisibility);
|
|
3716
|
+
const schedule = useMemo(() => ({
|
|
3717
|
+
...timing && { timing },
|
|
3718
|
+
...visibility && { visibility },
|
|
3719
|
+
...scheduleSyncProblem && { syncProblem: scheduleSyncProblem }
|
|
3720
|
+
}), [
|
|
3721
|
+
timing,
|
|
3722
|
+
visibility,
|
|
3723
|
+
scheduleSyncProblem
|
|
3724
|
+
]);
|
|
3725
|
+
const siteCss = useSiteCss(preview.stylesheets);
|
|
3726
|
+
const [choosing, setChoosing] = useState();
|
|
3727
|
+
const confirmLeave = t("unsaved.confirm");
|
|
3728
|
+
const services = useMemo(() => ({
|
|
3729
|
+
source: frames,
|
|
3730
|
+
init: {
|
|
3731
|
+
content: state.content,
|
|
3732
|
+
media: state.media,
|
|
3733
|
+
schedule,
|
|
3734
|
+
siteUrl,
|
|
3735
|
+
preview,
|
|
3736
|
+
strings,
|
|
3737
|
+
theme,
|
|
3738
|
+
hostName: account?.hostName ?? ""
|
|
3739
|
+
},
|
|
3740
|
+
siteCss,
|
|
3741
|
+
mediaPreviews,
|
|
3742
|
+
chooseMedia: (kind) => new Promise((resolve) => {
|
|
3743
|
+
setChoosing((current) => {
|
|
3744
|
+
current?.resolve(null);
|
|
3745
|
+
return {
|
|
3746
|
+
kind,
|
|
3747
|
+
resolve
|
|
3748
|
+
};
|
|
3749
|
+
});
|
|
3750
|
+
}),
|
|
3751
|
+
navigate: (href) => navigate(href, () => window.confirm(confirmLeave))
|
|
3752
|
+
}), [
|
|
3753
|
+
frames,
|
|
3754
|
+
state,
|
|
3755
|
+
schedule,
|
|
3756
|
+
siteUrl,
|
|
3757
|
+
preview,
|
|
3758
|
+
strings,
|
|
3759
|
+
theme,
|
|
3760
|
+
account,
|
|
3761
|
+
siteCss,
|
|
3762
|
+
mediaPreviews,
|
|
3763
|
+
confirmLeave
|
|
3764
|
+
]);
|
|
3765
|
+
return /* @__PURE__ */ jsxs(FrameServicesProvider, {
|
|
3766
|
+
value: services,
|
|
3767
|
+
children: [
|
|
3768
|
+
children,
|
|
3769
|
+
/* @__PURE__ */ jsx(SiteCodeFrame, { onClient: onCodeClient }),
|
|
3770
|
+
choosing && /* @__PURE__ */ jsx(MediaPickerDialog, {
|
|
3771
|
+
kind: choosing.kind,
|
|
3772
|
+
onSelect: (url) => {
|
|
3773
|
+
choosing.resolve(url);
|
|
3774
|
+
setChoosing(void 0);
|
|
3775
|
+
},
|
|
3776
|
+
onClose: () => {
|
|
3777
|
+
choosing.resolve(null);
|
|
3778
|
+
setChoosing(void 0);
|
|
3779
|
+
}
|
|
3780
|
+
})
|
|
3781
|
+
]
|
|
3782
|
+
});
|
|
3783
|
+
}
|
|
3784
|
+
/** Whether the signed-in person may change Site Settings, blocks, and collections' settings and templates. */
|
|
3785
|
+
function useCanChangeSettings() {
|
|
3786
|
+
return useAdmin().account?.role !== "editor";
|
|
3787
|
+
}
|
|
3788
|
+
function useAdmin() {
|
|
3789
|
+
const value = useContext(AdminContext);
|
|
3790
|
+
if (!value) throw new Error("useAdmin() must be used inside <Admin>.");
|
|
3791
|
+
return value;
|
|
3792
|
+
}
|
|
3793
|
+
/** The loaded site. Only call from screens rendered once loading has finished. */
|
|
3794
|
+
function useSiteContent() {
|
|
3795
|
+
const { state } = useAdmin();
|
|
3796
|
+
if (state.status !== "ready") throw new Error("The site isn't loaded yet.");
|
|
3797
|
+
return state;
|
|
3798
|
+
}
|
|
3799
|
+
//#endregion
|
|
3800
|
+
//#region src/preview.tsx
|
|
3801
|
+
function upsert(doc, key, tag, setup) {
|
|
3802
|
+
let element = doc.head.querySelector(`[data-gf="${key}"]`);
|
|
3803
|
+
if (!element) {
|
|
3804
|
+
element = doc.createElement(tag);
|
|
3805
|
+
element.dataset.gf = key;
|
|
3806
|
+
setup(element);
|
|
3807
|
+
doc.head.append(element);
|
|
3808
|
+
}
|
|
3809
|
+
return element;
|
|
3810
|
+
}
|
|
3811
|
+
/**
|
|
3812
|
+
* Styles a preview document like the live site: the site's compiled CSS, the
|
|
3813
|
+
* theme being edited, and the custom CSS being edited. Tailwind runs in the
|
|
3814
|
+
* browser too, so classes typed in the editor take effect before the site is rebuilt.
|
|
3815
|
+
*/
|
|
3816
|
+
function installPreviewStyles(doc, options, styles) {
|
|
3817
|
+
doc.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, styles.theme.darkMode ? styles.scheme : "light");
|
|
3818
|
+
options.stylesheets.forEach((href, index) => {
|
|
3819
|
+
upsert(doc, `stylesheet-${index}`, "link", (link) => {
|
|
3820
|
+
link.rel = "stylesheet";
|
|
3821
|
+
link.href = href;
|
|
3822
|
+
});
|
|
3823
|
+
});
|
|
3824
|
+
const fonts = googleFontsUrl(styles.theme);
|
|
3825
|
+
const fontsLink = doc.head.querySelector("[data-gf=\"fonts\"]");
|
|
3826
|
+
if (fonts) upsert(doc, "fonts", "link", (link) => {
|
|
3827
|
+
link.rel = "stylesheet";
|
|
3828
|
+
}).href = fonts;
|
|
3829
|
+
else fontsLink?.remove();
|
|
3830
|
+
upsert(doc, "theme", "style", () => {}).textContent = themeToCss(styles.theme);
|
|
3831
|
+
const tailwindCss = escapeStyleText(`${options.themeCss}\n${styles.customCss}`);
|
|
3832
|
+
const current = doc.head.querySelector("[data-gf=\"tailwind\"]");
|
|
3833
|
+
if (current?.textContent !== tailwindCss) {
|
|
3834
|
+
current?.remove();
|
|
3835
|
+
const style = doc.createElement("style");
|
|
3836
|
+
style.type = "text/tailwindcss";
|
|
3837
|
+
style.dataset.gf = "tailwind";
|
|
3838
|
+
style.textContent = tailwindCss;
|
|
3839
|
+
doc.head.append(style);
|
|
3840
|
+
}
|
|
3841
|
+
if (options.tailwindBrowserUrl) {
|
|
3842
|
+
const url = options.tailwindBrowserUrl;
|
|
3843
|
+
upsert(doc, "tailwind-browser", "script", (script) => {
|
|
3844
|
+
script.src = url;
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
/** Keeps links in a preview from navigating away from the editor. */
|
|
3849
|
+
function preventNavigation(doc) {
|
|
3850
|
+
if (doc.documentElement.dataset.gfNoNavigate) return;
|
|
3851
|
+
doc.documentElement.dataset.gfNoNavigate = "true";
|
|
3852
|
+
doc.addEventListener("click", (event) => {
|
|
3853
|
+
if (event.target?.closest?.("a[href]")) event.preventDefault();
|
|
3854
|
+
});
|
|
3855
|
+
}
|
|
3856
|
+
/**
|
|
3857
|
+
* Applies preview styles to a document whenever the theme or custom CSS
|
|
3858
|
+
* change, and keeps its media showing: the site's own, or `media`, such as the
|
|
3859
|
+
* site's media as it was at an earlier publish.
|
|
3860
|
+
*/
|
|
3861
|
+
function usePreviewStyles(doc, styles, media) {
|
|
3862
|
+
const { preview, mediaPreviews: current } = useAdmin();
|
|
3863
|
+
const mediaPreviews = media ?? current;
|
|
3864
|
+
const dark = useDarkMode();
|
|
3865
|
+
const scheme = styles.scheme ?? (dark ? "dark" : "light");
|
|
3866
|
+
useEffect(() => {
|
|
3867
|
+
if (!doc) return;
|
|
3868
|
+
preventNavigation(doc);
|
|
3869
|
+
installPreviewStyles(doc, preview, {
|
|
3870
|
+
...styles,
|
|
3871
|
+
scheme
|
|
3872
|
+
});
|
|
3873
|
+
}, [
|
|
3874
|
+
doc,
|
|
3875
|
+
preview,
|
|
3876
|
+
styles,
|
|
3877
|
+
scheme
|
|
3878
|
+
]);
|
|
3879
|
+
useEffect(() => doc ? showMediaInPreview(doc, mediaPreviews) : void 0, [doc, mediaPreviews]);
|
|
3880
|
+
}
|
|
3881
|
+
const FRAME_WIDTH = 1280;
|
|
3882
|
+
/**
|
|
3883
|
+
* A scaled-down, read-only view of a page as it will look on a desktop screen,
|
|
3884
|
+
* rendered by the site's code in a sandboxed frame of its own, so the site's
|
|
3885
|
+
* styles and the admin panel's don't mix and its blocks can't reach the admin panel.
|
|
3886
|
+
*/
|
|
3887
|
+
function PreviewFrame({ title, show, media }) {
|
|
3888
|
+
const container = useRef(null);
|
|
3889
|
+
const [scale, setScale] = useState(.5);
|
|
3890
|
+
const [height, setHeight] = useState(800);
|
|
3891
|
+
const latest = useRef(show);
|
|
3892
|
+
latest.current = show;
|
|
3893
|
+
const { client, frameProps } = useFrame({
|
|
3894
|
+
mode: "render",
|
|
3895
|
+
onConnect: useCallback(async (client) => {
|
|
3896
|
+
if (latest.current) await client.call("render.show", latest.current);
|
|
3897
|
+
}, []),
|
|
3898
|
+
...media && { media }
|
|
3899
|
+
});
|
|
3900
|
+
useFrameUpdates(client);
|
|
3901
|
+
useEffect(() => {
|
|
3902
|
+
if (client && show) client.call("render.show", show).catch(() => {});
|
|
3903
|
+
}, [client, show]);
|
|
3904
|
+
useEffect(() => {
|
|
3905
|
+
const element = container.current;
|
|
3906
|
+
if (!element) return;
|
|
3907
|
+
let frame = 0;
|
|
3908
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
3909
|
+
if (!entry) return;
|
|
3910
|
+
const { width, height } = entry.contentRect;
|
|
3911
|
+
cancelAnimationFrame(frame);
|
|
3912
|
+
frame = requestAnimationFrame(() => {
|
|
3913
|
+
setScale(Math.min(1, width / FRAME_WIDTH));
|
|
3914
|
+
setHeight(height);
|
|
3915
|
+
});
|
|
3916
|
+
});
|
|
3917
|
+
observer.observe(element);
|
|
3918
|
+
return () => {
|
|
3919
|
+
cancelAnimationFrame(frame);
|
|
3920
|
+
observer.disconnect();
|
|
3921
|
+
};
|
|
3922
|
+
}, []);
|
|
3923
|
+
return /* @__PURE__ */ jsx("div", {
|
|
3924
|
+
ref: container,
|
|
3925
|
+
className: "gfa-preview",
|
|
3926
|
+
children: /* @__PURE__ */ jsx("iframe", {
|
|
3927
|
+
title,
|
|
3928
|
+
...frameProps,
|
|
3929
|
+
style: {
|
|
3930
|
+
width: FRAME_WIDTH,
|
|
3931
|
+
height: height / scale,
|
|
3932
|
+
transform: `scale(${scale})`
|
|
3933
|
+
}
|
|
3934
|
+
})
|
|
3935
|
+
});
|
|
3936
|
+
}
|
|
3937
|
+
//#endregion
|
|
3938
|
+
//#region src/entry-values.ts
|
|
3939
|
+
/**
|
|
3940
|
+
* Where the item's schedule is among the values Puck edits. Fields' names are
|
|
3941
|
+
* lowercase letters, numbers and hyphens, so none can have this one.
|
|
3942
|
+
*/
|
|
3943
|
+
const SCHEDULE_PROP = "_schedule";
|
|
3944
|
+
/** The values Puck edits: the entry's stored values, with every field present so Puck shows it, and its schedule. */
|
|
3945
|
+
function formValues(collection, entry) {
|
|
3946
|
+
const values = { ...entry.content.fields };
|
|
3947
|
+
for (const field of collection.settings.fields) if (values[field.name] === void 0 && field.type !== "number") values[field.name] = "";
|
|
3948
|
+
values[SCHEDULE_PROP] = scheduleOf(entry.content);
|
|
3949
|
+
return values;
|
|
3950
|
+
}
|
|
3951
|
+
//#endregion
|
|
3952
|
+
export { defaultStrings as $, MediaChoiceContext as A, storedCss as At, useFrameUpdates as B, navigate as C, newCollectionSettings as Ct, REGISTRY_VERSION as D, redirectsFileChange as Dt, useUnsavedChanges as E, pageTitle as Et, useOutsideHost as F, editedMarkdown as Ft, Button as G, CONNECT_MESSAGE as H, PublishFailure as I, formattedHtml as It, Field as J, Dialog as K, FrameServicesProvider as L, MediaField as M, storedEntryFields as Mt, MediaScreen as N, uniqueName as Nt, createMediaPreviews as O, siteSettingsFileChange as Ot, prepareUpload as P, updateSettingsFileChange as Pt, StringsProvider as Q, reloadFrames as R, hasUnsavedChanges as S, movePageChanges as St, useRoute as T, pageFileChange as Tt, createChannel as U, READY_MESSAGE as V, draftStarted as W, TextField as X, PublishedNotice as Y, useDebounced as Z, scheduleSettingsChanges as _, entryFileChange$1 as _t, AdminContext as a, mediaUrl as at, collectionHref as b, menusFileChange as bt, useCanChangeSettings as c, addressPatternFor as ct, useApplyThemeChoice as d, codeFileChanges as dt, useStringTable as et, useDarkMode as f, collectionFileChange$1 as ft, ScheduleStatus as g, duplicatePageData as gt, ScheduleFields as h, deleteCollectionChanges as ht, usePreviewStyles as i, fileName as it, MediaChooser as j, storedData as jt, showMediaInPreview as k, slugify as kt, useSiteContent as l, checkEntrySlug as lt, ComingUp as m, customCssFileChange as mt, formValues as n, describeContrast as nt, AdminProvider as o, siteImages as ot, useThemeChoice as p, collectionSettingsChanges as pt, ErrorMessage as q, PreviewFrame as r, findPage as rt, useAdmin as s, uploadName as st, SCHEDULE_PROP as t, useStrings as tt, setThemeChoice as u, checkPageAddress as ut, Link as v, layoutFileChange as vt, pageEditorHref as w, newPageData as wt, entryEditorHref as x, moveEntryChanges as xt, SETTINGS_SECTIONS as y, mediaFileChange as yt, useFrame as z };
|