@goodfellow-cms/admin 0.5.0 → 0.7.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/dev.d.ts +4 -2
- package/dist/dev.js +21 -1
- package/dist/editor.js +752 -14
- package/dist/{entry-values-ocbalc9U.js → entry-values-DO01xZ9L.js} +1975 -513
- package/dist/index.d.ts +340 -11
- package/dist/index.js +5377 -3058
- package/dist/plugin-protocol-DkvBDqsF.js +119 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.js +153 -0
- package/package.json +12 -6
- package/plugin.css +219 -0
- package/styles.css +385 -6
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as CONNECT_MESSAGE, r as createChannel } from "./plugin-protocol-DkvBDqsF.js";
|
|
2
|
+
import { APP_API_HEADER, COLOR_SCHEME_ATTRIBUTE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, MAX_UPLOAD_BYTES, MEDIA_DIR, MENUS_FILE, PluginTaskError, REDIRECTS_FILE, SHADCN_NAMESPACE, SHADCN_REGISTRY, SITE_FILE, ScheduledBuildsError, SignInError, TASK_INPUT_LIMIT, 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, isStorableAddress, keptDrafts, layoutFileChange, limitsFor, loadSiteContent, markdownToHtml, mayChangeEntry, mayChangeLayout, mayChangeMedia, mayChangeMenus, mayChangePage, mediaFileChange, mediaKind, mediaKind as mediaKind$1, mediaStorageFileChange, memoryKeeper, menusFileChange, moveEntryChanges, movePageChanges, newCollectionSettings, newDraftName, newPageData, newTaskRequestId, openTaskAnswer, outsideHost, outsideLimits, pageFileChange, pageTitle as pageTitle$1, pluginFrameUrl, pluginMayList, pluginMayUse, publishProblems, redirectsFileChange, relinkSite, relinkedChanges, reviewersFor, scheduleOf, scheduleProblem, scheduleState, siteLanguages, siteSettingsFileChange, slugify, storedCss, storedData, storedEntryFields, storedMediaUrl, taskKeys, themeToCss, todayIn, trimChars, uniqueName, upcomingChanges, updateSettingsFileChange, uploadName, wallClock, withStoredFiles, withoutCredits, writeChanges } from "@goodfellow-cms/core";
|
|
2
3
|
import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
4
|
import TurndownService from "turndown";
|
|
4
5
|
import { gfm } from "turndown-plugin-gfm";
|
|
@@ -155,6 +156,12 @@ function mediaPath(url) {
|
|
|
155
156
|
if (!rest || rest.split("/").some((part) => part === "" || part === "." || part === "..")) return void 0;
|
|
156
157
|
return `${MEDIA_DIR}/${decodeURIComponent(rest)}`;
|
|
157
158
|
}
|
|
159
|
+
/** A file size in words: `2.4 MB`. */
|
|
160
|
+
function formatSize(bytes) {
|
|
161
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
162
|
+
if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
|
|
163
|
+
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
164
|
+
}
|
|
158
165
|
/** Where a media file is used, by name, so editors know what deleting or replacing it affects. */
|
|
159
166
|
function mediaUsage(content, url, names) {
|
|
160
167
|
const uses = (value) => JSON.stringify(value).includes(url);
|
|
@@ -162,7 +169,7 @@ function mediaUsage(content, url, names) {
|
|
|
162
169
|
if (uses(content.settings)) used.push(names.settings);
|
|
163
170
|
if (uses(content.header)) used.push(names.header);
|
|
164
171
|
if (uses(content.footer)) used.push(names.footer);
|
|
165
|
-
for (const page of content.pages) if (uses(page.content)) used.push(pageTitle(page));
|
|
172
|
+
for (const page of content.pages) if (uses(page.content)) used.push(pageTitle$1(page));
|
|
166
173
|
for (const collection of content.collections) {
|
|
167
174
|
if (uses(collection.settings)) used.push(collection.settings.name);
|
|
168
175
|
for (const entry of collection.entries) if (uses(entry.content)) used.push(entryTitle(entry));
|
|
@@ -174,12 +181,25 @@ function mediaUsage(content, url, names) {
|
|
|
174
181
|
function siteImages(media) {
|
|
175
182
|
return media.filter((path) => mediaKind(path) === "image").map(mediaUrl);
|
|
176
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Every media file the site has, as `public/media/…` paths: those in the
|
|
186
|
+
* site's own files and those a plugin keeps outside them
|
|
187
|
+
* (`content/media-storage.json`), which content refers to the same way.
|
|
188
|
+
*/
|
|
189
|
+
function allMedia(content, media) {
|
|
190
|
+
const stored = Object.keys(content.mediaStorage?.files ?? {}).flatMap((url) => mediaPath(url) ?? []);
|
|
191
|
+
return [.../* @__PURE__ */ new Set([...media, ...stored])];
|
|
192
|
+
}
|
|
193
|
+
/** Whether a plugin keeps a media file outside the site's own files. */
|
|
194
|
+
function isStoredMedia(content, url) {
|
|
195
|
+
return Object.hasOwn(content.mediaStorage?.files ?? {}, url);
|
|
196
|
+
}
|
|
177
197
|
//#endregion
|
|
178
198
|
//#region src/site-code.ts
|
|
179
199
|
/**
|
|
180
200
|
* 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),
|
|
182
|
-
* publish. The admin panel's page never runs that code: it asks the frame that
|
|
201
|
+
* (a calendar's months, a list's later pages), pages rendered to check a
|
|
202
|
+
* publish, and blocks drawn for editor plugins. The admin panel's page never runs that code: it asks the frame that
|
|
183
203
|
* does (`frameSiteCode()` in `frame/host.tsx`), which answers with `localSiteCode()` (`frame/local-code.ts`).
|
|
184
204
|
*/
|
|
185
205
|
/** The page at an address, as `findSitePage()` gives it: a page a build writes, or "Page not found". */
|
|
@@ -301,7 +321,7 @@ function problemPlace(t, content, file) {
|
|
|
301
321
|
if (file === SITE_FILE) return t("checks.place.settings");
|
|
302
322
|
if (file === REDIRECTS_FILE) return t("checks.place.redirects");
|
|
303
323
|
const page = content.pages.find((candidate) => candidate.file === file);
|
|
304
|
-
if (page) return t("checks.place.page", { title: pageTitle(page) });
|
|
324
|
+
if (page) return t("checks.place.page", { title: pageTitle$1(page) });
|
|
305
325
|
for (const collection of content.collections) {
|
|
306
326
|
if (collection.file === file) return t("checks.place.design", { collection: collection.settings.name });
|
|
307
327
|
const entry = collection.entries.find((candidate) => candidate.file === file);
|
|
@@ -399,6 +419,7 @@ const defaultStrings = {
|
|
|
399
419
|
"signIn.token.create": "Create a token on {host}",
|
|
400
420
|
"signIn.token.createBroad": "Create a broader token instead",
|
|
401
421
|
"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.",
|
|
422
|
+
"signIn.token.apiTokenHint": "Use this if you can't create a token for the site itself. Paste your Atlassian account's email address, a colon, then the token, such as name@example.org:ATATT3x…, and give the token permission to read and write repositories, pull requests and pipelines.",
|
|
402
423
|
"signIn.token.chooseSite": "If {host} asks what the token can access, choose this site.",
|
|
403
424
|
"signIn.token.step2": "Copy the token and paste it here:",
|
|
404
425
|
"signIn.token.label": "Access token",
|
|
@@ -471,6 +492,18 @@ const defaultStrings = {
|
|
|
471
492
|
"editor.viewport.phone": "Phone",
|
|
472
493
|
"editor.failed": "The editor stopped working. Reload the page to start it again.",
|
|
473
494
|
"editor.frame": "Editing {title}",
|
|
495
|
+
"editor.choose": "Editor",
|
|
496
|
+
"editor.blockSearch": "Search blocks",
|
|
497
|
+
"editor.blockSearchNone": "No blocks match. Try another word, such as \"picture\" or \"columns\".",
|
|
498
|
+
"editor.blockSearchFound": "Blocks found",
|
|
499
|
+
"editor.standard": "Standard editor",
|
|
500
|
+
"editor.plugin.switchLoses": "Some of what you changed in {editor} can't be kept in the other editor, so switching now leaves behind everything you changed since you opened {editor}. Switch anyway?\n\n{problems}",
|
|
501
|
+
"editor.plugin.pageSettings": "The page's settings",
|
|
502
|
+
"editor.plugin.problem": "{block}: {message}",
|
|
503
|
+
"editor.plugin.problemField": "{block}, {field}: {message}",
|
|
504
|
+
"editor.plugin.loading": "Opening {editor}…",
|
|
505
|
+
"editor.plugin.failed": "{editor} didn't start. Choose the standard editor to keep editing.",
|
|
506
|
+
"editor.plugin.invalid": "{editor} gave back something this site can't publish, so nothing was published. Try again, or choose the standard editor.",
|
|
474
507
|
"publish.publishing": "Publishing…",
|
|
475
508
|
"publish.done": "Published.",
|
|
476
509
|
"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.",
|
|
@@ -497,6 +530,9 @@ const defaultStrings = {
|
|
|
497
530
|
"draft.leave": "Back to the live site",
|
|
498
531
|
"draft.waiting": "A draft started {date} is waiting to be published.",
|
|
499
532
|
"draft.waitingMany": "{count} drafts are waiting to be published.",
|
|
533
|
+
"draft.reviewWaiting": "A draft is waiting for review: {title}.",
|
|
534
|
+
"draft.reviewWaitingMany": "{count} drafts are waiting for review.",
|
|
535
|
+
"draft.inReview": "Submitted for review.",
|
|
500
536
|
"draft.choose": "Draft to open",
|
|
501
537
|
"draft.started": "Started {date}",
|
|
502
538
|
"draft.open": "Open",
|
|
@@ -516,6 +552,22 @@ const defaultStrings = {
|
|
|
516
552
|
"checks.alsoWarnings": "These could be better too:",
|
|
517
553
|
"checks.publishAnyway": "Publish anyway",
|
|
518
554
|
"checks.goBack": "Go back",
|
|
555
|
+
"limits.title": "Some of this is outside what you can change",
|
|
556
|
+
"limits.intro": "An owner has limited what you can change on this site, and these changes are outside it:",
|
|
557
|
+
"limits.review": "Submit them for review instead: the site's reviewers are asked to look, and can publish them for you.",
|
|
558
|
+
"limits.noReview": "Ask an owner to make these changes for you, or to let you make them on Site Settings' Editors screen.",
|
|
559
|
+
"limits.goBack": "Go back",
|
|
560
|
+
"limits.submitting": "Submitting for review…",
|
|
561
|
+
"limits.submitted": "Submitted for review. The live site stays as it is until a reviewer publishes it.",
|
|
562
|
+
"limits.submitError": "The changes couldn't be submitted for review. Check your connection and try again.",
|
|
563
|
+
"limits.page": "The page {name}",
|
|
564
|
+
"limits.entry": "{name}, in {collection}",
|
|
565
|
+
"limits.media": "The media library",
|
|
566
|
+
"limits.layout": "The header and footer",
|
|
567
|
+
"limits.menus": "The menus",
|
|
568
|
+
"limits.other": "The site's settings, or other things only owners change",
|
|
569
|
+
"limits.readOnly": "You can look at this, but an owner has limited what you can change, and it's outside it.",
|
|
570
|
+
"limits.mediaReadOnly": "An owner has limited what you can change, and the media library is outside it. You can still use its files on pages you can change.",
|
|
519
571
|
"checks.missing-page": "{place} links to {target}, which isn't a page on the site.",
|
|
520
572
|
"checks.missing-media": "{place} uses {target}, which isn't in the media library.",
|
|
521
573
|
"checks.no-title": "{place} has no title, so browser tabs and search results show only the site's name.",
|
|
@@ -547,6 +599,7 @@ const defaultStrings = {
|
|
|
547
599
|
"pages.empty": "This site has no pages yet.",
|
|
548
600
|
"pages.column.title": "Title",
|
|
549
601
|
"pages.column.address": "Address",
|
|
602
|
+
"pages.look": "Look",
|
|
550
603
|
"pages.edit": "Edit",
|
|
551
604
|
"pages.move": "Change address",
|
|
552
605
|
"pages.duplicate": "Duplicate",
|
|
@@ -608,7 +661,7 @@ const defaultStrings = {
|
|
|
608
661
|
"deletePage.submit": "Delete page",
|
|
609
662
|
"deletePage.message": "Delete page \"{title}\"",
|
|
610
663
|
"address.invalid": "Use lowercase letters, numbers and hyphens, with / between parts, such as /about-us.",
|
|
611
|
-
"address.reserved": "This address
|
|
664
|
+
"address.reserved": "This address can't be used: the admin panel uses it, or it ends in /index, which is the page above it. Choose another.",
|
|
612
665
|
"address.taken": "Another page already uses this address.",
|
|
613
666
|
"field.required": "This can't be empty.",
|
|
614
667
|
"editPage.message": "Update page \"{title}\"",
|
|
@@ -619,7 +672,7 @@ const defaultStrings = {
|
|
|
619
672
|
"versions.back": "Back to editing",
|
|
620
673
|
"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
674
|
"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
|
|
675
|
+
"versions.local": "Version history is in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
|
|
623
676
|
"versions.demo": "Version history isn't kept in a demo.",
|
|
624
677
|
"versions.loading": "Loading versions…",
|
|
625
678
|
"versions.loadingVersion": "Loading this version…",
|
|
@@ -686,7 +739,7 @@ const defaultStrings = {
|
|
|
686
739
|
"newEntry.addressHint": "The last part of the page's address, as in {example}.",
|
|
687
740
|
"newEntry.message": "Add {entry} \"{title}\"",
|
|
688
741
|
"entryName.invalid": "Use lowercase letters, numbers and hyphens only, such as easter-vigil.",
|
|
689
|
-
"entryName.reserved": "This address
|
|
742
|
+
"entryName.reserved": "This address can't be used: the admin panel uses it, or it ends in /index, which is the page above it. Choose another.",
|
|
690
743
|
"entryName.taken": "Something else on the site already uses this address.",
|
|
691
744
|
"moveEntry.message": "Move {entry} \"{title}\" from {from} to {to}",
|
|
692
745
|
"deleteEntry.body": "{title} will be removed from {name}.",
|
|
@@ -863,14 +916,96 @@ const defaultStrings = {
|
|
|
863
916
|
"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
917
|
"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
918
|
"media.choose": "Choose image",
|
|
919
|
+
"media.chooseAudio": "Choose sound file",
|
|
866
920
|
"media.chooseFile": "Choose file",
|
|
867
921
|
"media.chooseVideo": "Choose video",
|
|
868
922
|
"media.pickerTitle": "Choose an image",
|
|
923
|
+
"media.pickerTitleAudio": "Choose a sound file",
|
|
869
924
|
"media.pickerTitleFile": "Choose a file",
|
|
870
925
|
"media.pickerTitleVideo": "Choose a video",
|
|
871
926
|
"media.pickerEmpty": "Nothing has been uploaded yet. Upload a file to use it here.",
|
|
872
927
|
"media.clear": "Remove",
|
|
928
|
+
"table.addRow": "Add a row",
|
|
929
|
+
"table.addColumn": "Add a column",
|
|
930
|
+
"table.removeRow": "Remove this row",
|
|
931
|
+
"table.removeRowNumber": "Remove row {row}",
|
|
932
|
+
"table.removeColumn": "Remove this column",
|
|
933
|
+
"table.removeColumnNumber": "Remove column {column}",
|
|
934
|
+
"table.cell": "Row {row}, column {column}",
|
|
935
|
+
"style.align": "Text alignment",
|
|
936
|
+
"style.align.default": "As the block has it",
|
|
937
|
+
"style.align.left": "Left",
|
|
938
|
+
"style.align.center": "Centered",
|
|
939
|
+
"style.align.right": "Right",
|
|
940
|
+
"style.align.justify": "Justified",
|
|
941
|
+
"style.fontSize": "Text size",
|
|
942
|
+
"style.fontSize.xs": "Extra small",
|
|
943
|
+
"style.fontSize.sm": "Small",
|
|
944
|
+
"style.fontSize.base": "Normal",
|
|
945
|
+
"style.fontSize.lg": "Large",
|
|
946
|
+
"style.fontSize.xl": "Extra large",
|
|
947
|
+
"style.fontSize.2xl": "Very large",
|
|
948
|
+
"style.fontSize.3xl": "Huge",
|
|
949
|
+
"style.fontSize.4xl": "Very huge",
|
|
950
|
+
"style.fontSize.5xl": "Giant",
|
|
951
|
+
"style.fontSize.6xl": "Enormous",
|
|
952
|
+
"style.color": "Text color",
|
|
953
|
+
"style.background": "Background color",
|
|
954
|
+
"style.colorDefault": "As the block has it",
|
|
955
|
+
"style.colorCustom": "Choose a color",
|
|
956
|
+
"style.contrast": "This text may be hard to read on this background. Try colors further apart.",
|
|
957
|
+
"style.padding": "Space inside",
|
|
958
|
+
"style.margin": "Space around",
|
|
959
|
+
"style.eachSide": "Set each side",
|
|
960
|
+
"style.side.top": "Top",
|
|
961
|
+
"style.side.right": "Right",
|
|
962
|
+
"style.side.bottom": "Bottom",
|
|
963
|
+
"style.side.left": "Left",
|
|
964
|
+
"style.allSides": "All sides",
|
|
965
|
+
"style.none": "None",
|
|
966
|
+
"style.radius": "Rounded corners",
|
|
967
|
+
"style.radius.none": "Square",
|
|
968
|
+
"style.radius.sm": "Slightly rounded",
|
|
969
|
+
"style.radius.md": "Rounded",
|
|
970
|
+
"style.radius.lg": "The theme's rounding",
|
|
971
|
+
"style.radius.xl": "Very rounded",
|
|
972
|
+
"style.radius.full": "Round",
|
|
973
|
+
"style.default": "As the block has it",
|
|
974
|
+
"style.custom": "Your own…",
|
|
975
|
+
"style.customLength": "Your own size, such as 12px or 1.5rem",
|
|
976
|
+
"style.customLengthInvalid": "Write a number with px, rem, em or %, such as 12px.",
|
|
977
|
+
"style.other": "Other CSS classes",
|
|
978
|
+
"style.otherHint": "Tailwind classes, or your own from Custom CSS, separated by spaces.",
|
|
979
|
+
"style.reset": "Back to the block's own look",
|
|
873
980
|
"media.credit": "Credit: {credit}",
|
|
981
|
+
"media.stored": "Kept by {plugin}",
|
|
982
|
+
"media.error.storageSize": "{name} is too large: {plugin} takes files up to {size}.",
|
|
983
|
+
"media.storage.title": "Where media is kept",
|
|
984
|
+
"media.storage.intro": "{plugin} keeps the site's media in its storage, including large files such as long videos. It keeps {count} files now. Pages show them from there, at the same addresses.",
|
|
985
|
+
"media.storage.missing": "{count} files are kept in storage by {plugin}, which the site no longer has. Pages still show them, but new files go into the site's own files.",
|
|
986
|
+
"media.storage.notReady": "{plugin} can't take files yet. {message}",
|
|
987
|
+
"media.storage.unavailable": "{plugin} didn't answer. Reload the admin panel and try again.",
|
|
988
|
+
"media.storage.settings": "Open {plugin}'s settings",
|
|
989
|
+
"media.storage.failed": "{plugin} couldn't keep {name}.",
|
|
990
|
+
"media.storage.uploading": "Uploading to {plugin}… This takes about a minute.",
|
|
991
|
+
"media.storage.wait": "Each group of files takes about a minute.",
|
|
992
|
+
"media.move.button": "Move {count} files to {plugin}",
|
|
993
|
+
"media.move.title": "Move the site's media to {plugin}?",
|
|
994
|
+
"media.move.body": "{plugin} will keep these files, and pages will show them from its storage at the same addresses, so nothing on them changes. They're taken out of the site's own files; Version history keeps them.",
|
|
995
|
+
"media.move.moving": "Moved {done} of {total}…",
|
|
996
|
+
"media.move.busy": "Moving…",
|
|
997
|
+
"media.move.nothing": "No files were moved.",
|
|
998
|
+
"media.move.some": "Moved {count} files to {plugin}, then stopped.",
|
|
999
|
+
"media.move.done": "Moved {count} files to {plugin}.",
|
|
1000
|
+
"media.move.message": "Move media to {plugin}",
|
|
1001
|
+
"media.back.button": "Move back to the site",
|
|
1002
|
+
"media.back.title": "Move the media back from {plugin}?",
|
|
1003
|
+
"media.back.body": "The files {plugin} keeps are copied back into the site's own files, at the same addresses, and new files go there too. Each can be up to 25 MB.",
|
|
1004
|
+
"media.back.empty": "{plugin} keeps no files now. New files will go into the site's own files.",
|
|
1005
|
+
"media.back.large": "These are larger than 25 MB, so {plugin} keeps them: {names}",
|
|
1006
|
+
"media.back.failed": "{name} couldn't be copied back from {plugin}'s storage, which may not allow it.",
|
|
1007
|
+
"media.back.done": "Moved {count} files back from {plugin}.",
|
|
1008
|
+
"media.back.message": "Move media back from {plugin}",
|
|
874
1009
|
"ai.label": "AI",
|
|
875
1010
|
"ai.title": "Write with AI",
|
|
876
1011
|
"ai.mode.add": "Describe what to add to this page, and AI will write it at the end of the page.",
|
|
@@ -952,9 +1087,10 @@ const defaultStrings = {
|
|
|
952
1087
|
"settings.previewEmpty": "Add a home page to see a preview here.",
|
|
953
1088
|
"domain.intro": "Connect a domain you own, such as example.org, so the site is at that address.",
|
|
954
1089
|
"domain.current": "The site is at {address} now.",
|
|
1090
|
+
"domain.copy": "This admin panel is a copy of the site's, such as a staging site, at {address}. That address is set where the copy is built, so the domain this screen connects is the main site's.",
|
|
955
1091
|
"domain.connected": "Connected to {domain}.",
|
|
956
1092
|
"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
|
|
1093
|
+
"domain.local": "Domains are connected in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
|
|
958
1094
|
"domain.admin.title": "Admin address",
|
|
959
1095
|
"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
1096
|
"domain.admin.unsupported": "{host} serves each site at one address, so the admin panel can't have one of its own here.",
|
|
@@ -1047,7 +1183,7 @@ const defaultStrings = {
|
|
|
1047
1183
|
"owner.token.paste": "Copy the token and paste it here:",
|
|
1048
1184
|
"settings.tab.security": "Security",
|
|
1049
1185
|
"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
|
|
1186
|
+
"security.local": "The sign-in page is set in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
|
|
1051
1187
|
"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
1188
|
"security.broker.title": "Sign-in page",
|
|
1053
1189
|
"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.",
|
|
@@ -1124,8 +1260,186 @@ const defaultStrings = {
|
|
|
1124
1260
|
"settings.tab.updates": "Updates",
|
|
1125
1261
|
"settings.tab.history": "Site history",
|
|
1126
1262
|
"settings.tab.import": "WordPress",
|
|
1263
|
+
"settings.tab.themes": "Themes",
|
|
1264
|
+
"plugins.notFound": "The site has no plugin screen at this address.",
|
|
1265
|
+
"plugins.failed": "{plugin}'s screen couldn't start. If it keeps happening, the plugin may need updating.",
|
|
1266
|
+
"themes.intro": "A theme changes your site's look: its colors, fonts and the styles of its blocks, and sometimes its header and footer. Themes are only settings and styles, with no code, and you can change anything a theme sets afterwards in Colors & fonts and Custom CSS.",
|
|
1267
|
+
"themes.installed": "Installed theme",
|
|
1268
|
+
"themes.noneInstalled": "No theme is installed. Your site uses its own colors and fonts.",
|
|
1269
|
+
"themes.installedBadge": "Installed",
|
|
1270
|
+
"themes.release": "version {version}",
|
|
1271
|
+
"themes.updateAvailable": "Version {version} is out",
|
|
1272
|
+
"themes.updateTo": "Update to {version}",
|
|
1273
|
+
"themes.seeVersion": "See version {version}",
|
|
1274
|
+
"themes.upToDate": "Up to date",
|
|
1275
|
+
"themes.show": "Show",
|
|
1276
|
+
"themes.showLabel": "Show {name}",
|
|
1277
|
+
"themes.find": "Find a theme",
|
|
1278
|
+
"themes.search": "Search themes",
|
|
1279
|
+
"themes.searchButton": "Search",
|
|
1280
|
+
"themes.loading": "Looking for themes…",
|
|
1281
|
+
"themes.noneFound": "No themes were found.",
|
|
1282
|
+
"themes.byPublisher": "{name}, published by {publisher}",
|
|
1283
|
+
"themes.releaseDetails": "{name}, version {version}, published {date}",
|
|
1284
|
+
"themes.provenance": "Built from {source}, which npm checked when it was published.",
|
|
1285
|
+
"themes.noProvenance": "There's no record of where this version was built, so nobody has checked it came from its publisher's own code.",
|
|
1286
|
+
"themes.waiting": "Version {version} can be installed once it's {days} days old, since most harmful versions are found by then.",
|
|
1287
|
+
"themes.incompatible": "Version {version} doesn't say it works with Goodfellow {goodfellow}.",
|
|
1288
|
+
"themes.tooNew": "This theme's versions are too new to install. Versions can be installed once they're {days} days old.",
|
|
1289
|
+
"themes.notCompatible": "No version of this theme says it works with Goodfellow {version}.",
|
|
1290
|
+
"themes.imageAlt": "A site with the {title} theme",
|
|
1291
|
+
"themes.loads": "This theme asks visitors' browsers for these files from other sites, which can tell those sites who visits yours:",
|
|
1292
|
+
"themes.useLayout": "Use the theme's header and footer in place of yours. Your menus, logo and contact details stay as they are.",
|
|
1293
|
+
"themes.layoutUnknown": "The theme's header and footer use blocks a theme can't add, or that your site doesn't have ({blocks}), so they can't be used.",
|
|
1294
|
+
"themes.kept": "You changed these since the version before, so they stay as you set them: {list}.",
|
|
1295
|
+
"themes.replaced": "You changed these, and installing this theme replaces your changes: {list}.",
|
|
1296
|
+
"themes.changed.css": "the theme's CSS",
|
|
1297
|
+
"themes.preview": "Your site with {title}",
|
|
1298
|
+
"themes.previewPage": "Page to look at",
|
|
1299
|
+
"themes.install": "Install and publish",
|
|
1300
|
+
"themes.installDemo": "Install",
|
|
1301
|
+
"themes.installMessage": "Install the {title} theme, version {version}",
|
|
1302
|
+
"themes.updateMessage": "Update the {title} theme to version {version}",
|
|
1303
|
+
"themes.installedNotice": "{title} is installed. Your site will show it once it's rebuilt.",
|
|
1304
|
+
"themes.updated": "{title} is updated to version {version}.",
|
|
1305
|
+
"themes.remove": "Remove",
|
|
1306
|
+
"themes.removeTitle": "Remove {title}?",
|
|
1307
|
+
"themes.removeBody": "Colors, fonts and styles the theme set go back to Goodfellow's defaults, and its CSS is removed. Anything you changed yourself, and your header and footer, stay as they are.",
|
|
1308
|
+
"themes.removeReplaced": "Removing it also removes your changes to: {list}.",
|
|
1309
|
+
"themes.removeMessage": "Remove the {title} theme",
|
|
1310
|
+
"themes.removed": "{title} is removed.",
|
|
1311
|
+
"themes.error.record": "The record of which theme is installed can't be read.",
|
|
1312
|
+
"themes.error.unreachable": "Themes can't be looked up right now. Check your internet connection and try again.",
|
|
1313
|
+
"themes.error.badFile": "This theme has a problem in its files, so it can't be installed. Its publisher needs to fix it.",
|
|
1314
|
+
"themes.error.notATheme": "This package isn't a Goodfellow theme.",
|
|
1315
|
+
"settings.tab.plugins": "Plugins",
|
|
1316
|
+
"plugins.intro": "Plugins add to what your site and this admin panel can do, such as blocks, screens here, and steps when your site is built. Unlike themes, they have code, which is included when your site is built and runs on its pages, so install only plugins you trust. Before you install one, this screen shows what it asks to do and reads its code for things to look at.",
|
|
1317
|
+
"plugins.where.none": "Plugins can be installed here once the admin panel has an address of its own, or a token broker, where your site's pages can't reach it: see the Domain and Security tabs. Until then, a developer can add plugins to the site's code.",
|
|
1318
|
+
"plugins.where.contentOnly": "This site's code is kept apart from its content, so a developer adds plugins there.",
|
|
1319
|
+
"plugins.where.demo": "Plugins can't be installed in a demo.",
|
|
1320
|
+
"plugins.where.local": "On this computer, a developer adds plugins with npm and lists them in goodfellow.config.tsx. The published admin panel installs them.",
|
|
1321
|
+
"plugins.where.setup": "Your site's build doesn't write the list of its packages' versions yet, which plugins installed here need. A developer needs to update its build setup to the starter's first.",
|
|
1322
|
+
"plugins.installed": "Installed plugins",
|
|
1323
|
+
"plugins.noneInstalled": "No plugins are installed.",
|
|
1324
|
+
"plugins.inCode": "Added by a developer",
|
|
1325
|
+
"plugins.installedBadge": "Installed",
|
|
1326
|
+
"plugins.release": "version {version}",
|
|
1327
|
+
"plugins.updateAvailable": "Version {version} is out",
|
|
1328
|
+
"plugins.updateTo": "Update to {version}",
|
|
1329
|
+
"plugins.seeVersion": "See version {version}",
|
|
1330
|
+
"plugins.upToDate": "Up to date",
|
|
1331
|
+
"plugins.show": "Show",
|
|
1332
|
+
"plugins.showLabel": "Show {name}",
|
|
1333
|
+
"plugins.find": "Find a plugin",
|
|
1334
|
+
"plugins.search": "Search plugins",
|
|
1335
|
+
"plugins.searchButton": "Search",
|
|
1336
|
+
"plugins.loading": "Looking for plugins…",
|
|
1337
|
+
"plugins.noneFound": "No plugins were found.",
|
|
1338
|
+
"plugins.byPublisher": "{name}, published by {publisher}",
|
|
1339
|
+
"plugins.releaseDetails": "{name}, version {version}, published {date}",
|
|
1340
|
+
"plugins.provenance": "Built from {source}, which npm checked when it was published.",
|
|
1341
|
+
"plugins.noProvenance": "There's no record of where this version was built, so nobody has checked it came from its publisher's own code.",
|
|
1342
|
+
"plugins.waiting": "Version {version} can be installed once it's {days} days old, since most harmful versions are found by then.",
|
|
1343
|
+
"plugins.incompatible": "Version {version} doesn't say it works with Goodfellow {goodfellow}.",
|
|
1344
|
+
"plugins.tooNew": "This plugin's versions are too new to install. Versions can be installed once they're {days} days old.",
|
|
1345
|
+
"plugins.notCompatible": "No version of this plugin says it works with Goodfellow {version}.",
|
|
1346
|
+
"plugins.asks": "What it asks to do",
|
|
1347
|
+
"plugins.asks.connect": "Reach these addresses from the admin panel: {list}",
|
|
1348
|
+
"plugins.asks.files": "Read and publish these files: {list}",
|
|
1349
|
+
"plugins.asks.secrets": "Get these secrets when your site is built: {list}",
|
|
1350
|
+
"plugins.asks.read": "Read these parts of your site when it's built: {list}",
|
|
1351
|
+
"plugins.asks.write": "Write these parts of the built site: {list}",
|
|
1352
|
+
"plugins.asks.blocks": "Add these blocks: {list}",
|
|
1353
|
+
"plugins.asks.admin": "Run in the admin panel, in a frame of its own that can't reach your sign-in",
|
|
1354
|
+
"plugins.asks.screens": "Add these screens to Site Settings: {list}",
|
|
1355
|
+
"plugins.asks.build": "Run these steps when your site is built: {list}",
|
|
1356
|
+
"plugins.asks.tasks": "Run these tasks with its secrets when the admin panel asks, each taking about a minute: {list}",
|
|
1357
|
+
"plugins.secrets.github.title": "Add them on GitHub",
|
|
1358
|
+
"plugins.secrets.github.link": "Open GitHub's form for a new secret",
|
|
1359
|
+
"plugins.secrets.github.open": "Open GitHub's form for a new secret with the link below. Add each key there, one at a time, opening the form again for the next.",
|
|
1360
|
+
"plugins.secrets.github.add": "For {name}: type {name} in Name, paste its value in Secret, and choose Add secret.",
|
|
1361
|
+
"plugins.secrets.github.who": "Only the site's owners on GitHub can open the form.",
|
|
1362
|
+
"plugins.secrets.github.kind": "This form adds secrets for this site alone, which is all it needs. GitHub's environment and organization secrets are for other things, so you can leave them alone.",
|
|
1363
|
+
"plugins.secrets.gitlab.title": "Add them on GitLab",
|
|
1364
|
+
"plugins.secrets.gitlab.link": "Open the site's CI/CD settings on GitLab",
|
|
1365
|
+
"plugins.secrets.gitlab.open": "Open the site's CI/CD settings on GitLab with the link below, and expand Variables. Add each key there, one at a time.",
|
|
1366
|
+
"plugins.secrets.gitlab.add": "For {name}: choose Add variable, choose Masked under Visibility, keep Protect variable ticked, type {name} in Key, paste its value in Value, and choose Add variable.",
|
|
1367
|
+
"plugins.secrets.gitlab.who": "Only the site's owners (Maintainers) on GitLab can change its CI/CD settings.",
|
|
1368
|
+
"plugins.secrets.bitbucket.title": "Add them on Bitbucket",
|
|
1369
|
+
"plugins.secrets.bitbucket.link": "Open the site's Pipelines variables on Bitbucket",
|
|
1370
|
+
"plugins.secrets.bitbucket.open": "Open the site's Pipelines variables on Bitbucket with the link below. Add each key there, one at a time. If Bitbucket says Pipelines isn't turned on, turn it on under Settings first.",
|
|
1371
|
+
"plugins.secrets.bitbucket.add": "For {name}: type {name} in Name, paste its value in Value, tick Secured, and choose Add.",
|
|
1372
|
+
"plugins.secrets.bitbucket.who": "Only the site's owners (with Admin permission) on Bitbucket can change its variables.",
|
|
1373
|
+
"plugins.secrets.development.title": "Add them on this computer",
|
|
1374
|
+
"plugins.secrets.development.file": "In the site's folder, beside package.json, open the file .env.local, or make it, and add a line for each of {names}:",
|
|
1375
|
+
"plugins.secrets.development.values": "Put each value after its =, save the file, and try again. There's no need to restart the development server.",
|
|
1376
|
+
"plugins.secrets.development.local": "This is only for trying the site on this computer: .env.local is never published. The live site needs them in its own secrets too.",
|
|
1377
|
+
"plugins.secrets.server.title": "Add them to the site's server",
|
|
1378
|
+
"plugins.secrets.server.add": "Add {names} as environment variables where the site's server runs, such as the project's Settings → Environment Variables on Vercel.",
|
|
1379
|
+
"plugins.secrets.server.deploy": "Then deploy the site again so the server has them.",
|
|
1380
|
+
"plugins.secrets.server.who": "Whoever set up the site's server knows where its environment variables are. Without them there, the build service's secrets below are used instead, which is slower.",
|
|
1381
|
+
"plugins.task.missing": "Your site's setup can't run plugins' tasks yet. A developer needs to copy the starter's plugin-task setup into the site.",
|
|
1382
|
+
"plugins.task.not-allowed": "Your sign-in can't run plugins' tasks. Ask the site's owner to let you publish.",
|
|
1383
|
+
"plugins.task.timeout": "The site's build service didn't answer in time. Try again in a few minutes.",
|
|
1384
|
+
"plugins.task.failed": "The site's build service couldn't run the plugin's task. Try again, or ask a developer to look at the site's builds.",
|
|
1385
|
+
"plugins.asks.server": "Answer requests on your site's server, with everything the server can do",
|
|
1386
|
+
"plugins.asks.mediaStorage": "Keep your site's media in a storage service, outside the site's own files",
|
|
1387
|
+
"plugins.asks.editor": "Offer its own page editor, {title}, which anyone editing the site can choose in place of the usual one, and which is given what they edit there",
|
|
1388
|
+
"plugins.asks.always": "Like any plugin, its code is included when your site is built, and its blocks run on your site's pages, where nothing holds them to what it asks.",
|
|
1389
|
+
"plugins.asks.new": "This version asks for more than the one installed:",
|
|
1390
|
+
"plugins.code": "What its code does",
|
|
1391
|
+
"plugins.code.intro": "The plugin's code, and the code of the packages it needs, read as text for things to look at. Code can hide what it does, so this is a place to start, not a promise.",
|
|
1392
|
+
"plugins.code.reading": "Reading its code…",
|
|
1393
|
+
"plugins.code.via": "needed by {via}",
|
|
1394
|
+
"plugins.code.none": "Nothing to look at was found.",
|
|
1395
|
+
"plugins.code.summary": "Its code: {list}.",
|
|
1396
|
+
"plugins.code.nothing": "Nothing to look at was found in its code.",
|
|
1397
|
+
"plugins.code.unreadSummary": "{count} of its packages couldn't be read in full.",
|
|
1398
|
+
"plugins.note.network": "makes network requests",
|
|
1399
|
+
"plugins.note.programs": "runs other programs on the computer that builds your site",
|
|
1400
|
+
"plugins.note.eval": "runs code made from text, which can't be read beforehand",
|
|
1401
|
+
"plugins.note.native": "includes native code, which can't be read",
|
|
1402
|
+
"plugins.note.install-script": "has a script npm would run when installing it, which your site's build doesn't run, since it installs packages with scripts turned off",
|
|
1403
|
+
"plugins.unread.too-large": "Too large to read in full.",
|
|
1404
|
+
"plugins.unread.too-many": "Not read: the plugin needs too many packages to read them all.",
|
|
1405
|
+
"plugins.unread.no-release": "No version of it is old enough yet, so your site couldn't be built with this plugin.",
|
|
1406
|
+
"plugins.unread.range": "Comes from somewhere other than npm, so it wasn't read.",
|
|
1407
|
+
"plugins.unread.unavailable": "Couldn't be read.",
|
|
1408
|
+
"plugins.install": "Install",
|
|
1409
|
+
"plugins.confirmTitle": "Install {title}?",
|
|
1410
|
+
"plugins.confirmIntro": "{name} version {version} asks to:",
|
|
1411
|
+
"plugins.confirmConfig": "Your site's settings file is changed too, to list the plugins installed here, since it didn't yet.",
|
|
1412
|
+
"plugins.confirmBuild": "Your site's build then writes down the versions of the packages it needs, without running any of their code, and rebuilds your site with it, which takes a few minutes.",
|
|
1413
|
+
"plugins.confirmReview": "Your site's server holds its secrets, so installing a plugin is submitted for review, for a developer to accept.",
|
|
1414
|
+
"plugins.confirmInstall": "Install and publish",
|
|
1415
|
+
"plugins.submit": "Submit for review",
|
|
1416
|
+
"plugins.reviewIntro": "Installs {name} version {version}, from the admin panel's Plugins screen. It asks to:",
|
|
1417
|
+
"plugins.installMessage": "Install the {title} plugin, version {version}",
|
|
1418
|
+
"plugins.updateMessage": "Update the {title} plugin to version {version}",
|
|
1419
|
+
"plugins.installedNotice": "{title} is installed. Your site will have it once it's rebuilt, in a few minutes.",
|
|
1420
|
+
"plugins.updated": "{title} is updated to version {version}.",
|
|
1421
|
+
"plugins.submitted": "The change to {title} is submitted for review.",
|
|
1422
|
+
"plugins.seeReview": "See it",
|
|
1423
|
+
"plugins.remove": "Remove",
|
|
1424
|
+
"plugins.removeReview": "Submit for review",
|
|
1425
|
+
"plugins.removeTitle": "Remove {title}?",
|
|
1426
|
+
"plugins.removeBody": "Its code is taken out of your site when it's next built. Files it kept in your site's content stay where they are.",
|
|
1427
|
+
"plugins.removeUsed": "Pages use its blocks ({blocks}), so it can't be removed. Take them off those pages first.",
|
|
1428
|
+
"plugins.removeKeepsMedia": "{title} keeps your site's media, which pages show from its storage. Move the media back into the site's own files on the Media screen first.",
|
|
1429
|
+
"plugins.removeMessage": "Remove the {title} plugin",
|
|
1430
|
+
"plugins.removed": "{title} is removed.",
|
|
1431
|
+
"plugins.error.record": "The record of which plugins are installed can't be read.",
|
|
1432
|
+
"plugins.error.unreachable": "Plugins can't be looked up right now. Check your internet connection and try again.",
|
|
1433
|
+
"plugins.error.badFile": "This plugin has a problem in its files, so it can't be installed. Its publisher needs to fix it.",
|
|
1434
|
+
"plugins.error.notAPlugin": "This package isn't a Goodfellow plugin.",
|
|
1435
|
+
"plugins.error.config": "Your site's settings file lists plugins of its own, so a developer needs to add the ones installed here to it first.",
|
|
1436
|
+
"plugins.error.inCode": "A developer added this plugin to the site's code, so it's changed there.",
|
|
1437
|
+
"plugins.error.blockClash": "This plugin's blocks have the same names as ones your site has: {blocks}.",
|
|
1438
|
+
"plugins.error.notInstalled": "This plugin wasn't installed here.",
|
|
1439
|
+
"updates.pluginRange": "{title}, a plugin installed here, says it works with Goodfellow {range}, so your site may not build with Goodfellow {version} until the plugin has a newer version.",
|
|
1440
|
+
"updates.themeRange": "Your theme, {title}, says it works with Goodfellow {range}. Goodfellow {version} may change how it looks, so check Themes for a newer version of it after updating.",
|
|
1127
1441
|
"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
|
|
1442
|
+
"siteHistory.local": "Site history is in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
|
|
1129
1443
|
"siteHistory.demo": "Site history isn't kept in a demo.",
|
|
1130
1444
|
"siteHistory.error": "The site's history couldn't be loaded. Check your connection and try again.",
|
|
1131
1445
|
"siteHistory.current": "The site now",
|
|
@@ -1153,6 +1467,7 @@ const defaultStrings = {
|
|
|
1153
1467
|
"siteHistory.subject.redirects": "Redirects",
|
|
1154
1468
|
"siteHistory.subject.credits": "Photo credits",
|
|
1155
1469
|
"siteHistory.subject.css": "Custom CSS",
|
|
1470
|
+
"siteHistory.subject.theme": "Which theme is installed",
|
|
1156
1471
|
"siteHistory.subject.code": "Code added to every page",
|
|
1157
1472
|
"import.title": "Import from WordPress",
|
|
1158
1473
|
"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.",
|
|
@@ -1190,7 +1505,7 @@ const defaultStrings = {
|
|
|
1190
1505
|
"import.note.embed": "Something from {name} is now a link to it: {items}.",
|
|
1191
1506
|
"import.note.form": "A form was left out. Add a Form block in its place: {items}.",
|
|
1192
1507
|
"import.note.script": "A script was left out: {items}.",
|
|
1193
|
-
"import.note.table": "A table became text, since
|
|
1508
|
+
"import.note.table": "A table became text, since the site has no Table block: {items}.",
|
|
1194
1509
|
"import.note.posts": "A list of the latest posts was left out of a post: {items}.",
|
|
1195
1510
|
"import.note.video": "A video is now a link to it, since posts show videos as links: {items}.",
|
|
1196
1511
|
"import.note.oldLinks": "Links that still go to the WordPress site, to pages that weren't imported: {count}, such as {url}.",
|
|
@@ -1232,7 +1547,7 @@ const defaultStrings = {
|
|
|
1232
1547
|
"updates.code.link": "See it on {host}",
|
|
1233
1548
|
"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
1549
|
"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
|
|
1550
|
+
"updates.local": "Updates are shown in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
|
|
1236
1551
|
"updates.demo": "Updates aren't installed in a demo.",
|
|
1237
1552
|
"updates.loading": "Checking for updates…",
|
|
1238
1553
|
"updates.current": "This site uses Goodfellow {version}.",
|
|
@@ -1299,8 +1614,8 @@ const defaultStrings = {
|
|
|
1299
1614
|
"updates.error.save": "The change couldn't be saved. Try again in a moment.",
|
|
1300
1615
|
"updates.error.other": "Something went wrong. Try again in a moment.",
|
|
1301
1616
|
"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
|
|
1617
|
+
"editors.roles": "Editors can change and publish everything in the admin panel, unless their limits below say otherwise. Owners can also invite and remove editors, limit what they can change, and connect a domain.",
|
|
1618
|
+
"editors.local": "Editors are invited in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
|
|
1304
1619
|
"editors.demo": "Editors can't be invited in a demo.",
|
|
1305
1620
|
"editors.loading": "Loading editors…",
|
|
1306
1621
|
"editors.onlyOwners": "Only the site's owners can invite and remove editors.",
|
|
@@ -1325,6 +1640,7 @@ const defaultStrings = {
|
|
|
1325
1640
|
"editors.invite.title": "Invite someone",
|
|
1326
1641
|
"editors.invite.field": "Their {host} username",
|
|
1327
1642
|
"editors.invite.hint": "They need a {host} account. {host} emails them an invitation, which they accept before they can sign in here.",
|
|
1643
|
+
"editors.invite.hintMembers": "They need to be in the site's {host} workspace already. They can edit as soon as you add them.",
|
|
1328
1644
|
"editors.invite.fieldEmail": "Their email address or {host} username",
|
|
1329
1645
|
"editors.invite.hintEmail": "With an email address, {host} emails them an invitation, and they make an account if they don't have one.",
|
|
1330
1646
|
"editors.invite.submit": "Invite",
|
|
@@ -1334,6 +1650,33 @@ const defaultStrings = {
|
|
|
1334
1650
|
"editors.error.invalid": "That isn't a username or email address that {host} accepts.",
|
|
1335
1651
|
"editors.error.already": "They already edit the site, or have been invited.",
|
|
1336
1652
|
"editors.error.other": "Something went wrong. Try again in a moment.",
|
|
1653
|
+
"permissions.title": "Limits",
|
|
1654
|
+
"permissions.intro": "Limit what someone can change: some pages, some collections, the media library, the header and footer, or the menus. Anyone not listed here can change everything their role allows. Changes outside someone's limits go to a reviewer, who publishes them or not.",
|
|
1655
|
+
"permissions.where": "The admin panel keeps people to their limits, so nobody changes the wrong page by mistake. People who can publish to the site on {host} could still change other files there, so only a site with its own server holds everyone to them.",
|
|
1656
|
+
"permissions.whereServer": "The site's server holds everyone to their limits, whatever they try.",
|
|
1657
|
+
"permissions.host": "the site's host",
|
|
1658
|
+
"permissions.person": "Their username",
|
|
1659
|
+
"permissions.personHint": "The name they sign in with, as the list above shows it.",
|
|
1660
|
+
"permissions.add": "Limit what they can change",
|
|
1661
|
+
"permissions.change": "Change",
|
|
1662
|
+
"permissions.remove": "Remove limits",
|
|
1663
|
+
"permissions.nothing": "Nothing yet",
|
|
1664
|
+
"permissions.allPages": "Every page",
|
|
1665
|
+
"permissions.editTitle": "What {name} can change",
|
|
1666
|
+
"permissions.editIntro": "Tick what they can change. They can look at everything else, and submit changes to it for review.",
|
|
1667
|
+
"permissions.pages": "Pages",
|
|
1668
|
+
"permissions.pagesHint": "Each with the pages under it, and their translations.",
|
|
1669
|
+
"permissions.collections": "Collections' items",
|
|
1670
|
+
"permissions.more": "Also",
|
|
1671
|
+
"permissions.languages": "Languages",
|
|
1672
|
+
"permissions.everyLanguage": "Every language",
|
|
1673
|
+
"permissions.save": "Publish limits",
|
|
1674
|
+
"permissions.message": "Change what {name} can change",
|
|
1675
|
+
"permissions.removeMessage": "Let {name} change everything",
|
|
1676
|
+
"permissions.reviewers": "Who reviews changes outside someone's limits",
|
|
1677
|
+
"permissions.reviewersHint": "Usernames, separated by commas. Leave it empty for every owner.",
|
|
1678
|
+
"permissions.saveReviewers": "Publish reviewers",
|
|
1679
|
+
"permissions.reviewersMessage": "Change who reviews changes outside editors' limits",
|
|
1337
1680
|
"general.siteTitle": "Site name",
|
|
1338
1681
|
"general.description": "Description",
|
|
1339
1682
|
"general.descriptionHint": "Shown by search engines when a page doesn't have its own description.",
|
|
@@ -1411,6 +1754,12 @@ const defaultStrings = {
|
|
|
1411
1754
|
"theme.bodyFont": "Text",
|
|
1412
1755
|
"theme.radius": "Corner rounding",
|
|
1413
1756
|
"theme.radiusHint": "Such as 0.5rem, or 0 for square corners.",
|
|
1757
|
+
"theme.parts": "Blocks' parts",
|
|
1758
|
+
"theme.partsHint": "Give a part of every block of one kind classes, such as rounded-xl shadow-sm, to change how they all look. They come after the block's own classes and before what's chosen for each block.",
|
|
1759
|
+
"theme.partsBlock": "Block",
|
|
1760
|
+
"theme.partsBlockStyled": "{block} ({count} styled)",
|
|
1761
|
+
"theme.partsName": "Its name is {name}, for custom CSS.",
|
|
1762
|
+
"theme.partsNone": "This site's blocks have no named parts.",
|
|
1414
1763
|
"theme.color.background": "Background",
|
|
1415
1764
|
"theme.color.foreground": "Text",
|
|
1416
1765
|
"theme.color.primary": "Main color",
|
|
@@ -1766,120 +2115,6 @@ function draftStarted(name) {
|
|
|
1766
2115
|
});
|
|
1767
2116
|
}
|
|
1768
2117
|
//#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
2118
|
//#region src/frame/protocol.ts
|
|
1884
2119
|
/** A frame sends this to the admin panel's page once it's listening, and the page answers with a port. */
|
|
1885
2120
|
const READY_MESSAGE = "goodfellow:frame-ready";
|
|
@@ -1894,7 +2129,7 @@ function frameClient(channel) {
|
|
|
1894
2129
|
};
|
|
1895
2130
|
}
|
|
1896
2131
|
/** 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;
|
|
2132
|
+
const MAX_CONNECTIONS$1 = 5;
|
|
1898
2133
|
/** Each frame's way of loading its page again, for `reloadFrames()`. */
|
|
1899
2134
|
const reloaders = /* @__PURE__ */ new Set();
|
|
1900
2135
|
/**
|
|
@@ -1919,7 +2154,7 @@ function useFrameServices() {
|
|
|
1919
2154
|
return value;
|
|
1920
2155
|
}
|
|
1921
2156
|
/** Whether a frame asked to go somewhere it may: one of the admin panel's own screens. */
|
|
1922
|
-
function isAdminScreen(href) {
|
|
2157
|
+
function isAdminScreen$1(href) {
|
|
1923
2158
|
return typeof href === "string" && /^#\/[\w\-/?=&%.]*$/.test(href);
|
|
1924
2159
|
}
|
|
1925
2160
|
/** Makes a frame of the site's code and connects to it. */
|
|
@@ -1945,13 +2180,17 @@ function useFrame({ mode, onConnect, media }) {
|
|
|
1945
2180
|
let stopped = false;
|
|
1946
2181
|
const handlers = {
|
|
1947
2182
|
navigate: (href) => {
|
|
1948
|
-
if (isAdminScreen(href)) latest.current.services.navigate(href);
|
|
2183
|
+
if (isAdminScreen$1(href)) latest.current.services.navigate(href);
|
|
1949
2184
|
},
|
|
1950
2185
|
"media.choose": (params) => {
|
|
1951
2186
|
const kind = params?.kind;
|
|
1952
|
-
if (kind !== "image" && kind !== "video" && kind !== "file") return null;
|
|
2187
|
+
if (kind !== "image" && kind !== "video" && kind !== "audio" && kind !== "file") return null;
|
|
1953
2188
|
return latest.current.services.chooseMedia(kind, typeof params.value === "string" ? params.value : "");
|
|
1954
2189
|
},
|
|
2190
|
+
"plugin.call": (params) => {
|
|
2191
|
+
if (mode !== "editor" || typeof params?.plugin !== "string" || typeof params.method !== "string") return null;
|
|
2192
|
+
return latest.current.services.callPlugin(params.plugin, params.method, Array.isArray(params.args) ? params.args : []);
|
|
2193
|
+
},
|
|
1955
2194
|
"media.bytes": async (url) => {
|
|
1956
2195
|
if (typeof url !== "string" || !url.startsWith("/media/") || url.includes("..")) return null;
|
|
1957
2196
|
return await (latest.current.media ?? latest.current.services.mediaPreviews).bytes(url) ?? null;
|
|
@@ -1963,7 +2202,7 @@ function useFrame({ mode, onConnect, media }) {
|
|
|
1963
2202
|
current?.close();
|
|
1964
2203
|
setClient(void 0);
|
|
1965
2204
|
connections += 1;
|
|
1966
|
-
if (connections > MAX_CONNECTIONS) {
|
|
2205
|
+
if (connections > MAX_CONNECTIONS$1) {
|
|
1967
2206
|
setFailed(true);
|
|
1968
2207
|
return;
|
|
1969
2208
|
}
|
|
@@ -2065,10 +2304,16 @@ function frameSiteCode(client) {
|
|
|
2065
2304
|
targets,
|
|
2066
2305
|
today
|
|
2067
2306
|
});
|
|
2307
|
+
},
|
|
2308
|
+
async drawBlock(content, target) {
|
|
2309
|
+
return (await client()).call("code.drawBlock", {
|
|
2310
|
+
content,
|
|
2311
|
+
target
|
|
2312
|
+
});
|
|
2068
2313
|
}
|
|
2069
2314
|
};
|
|
2070
2315
|
}
|
|
2071
|
-
const HIDDEN = {
|
|
2316
|
+
const HIDDEN$1 = {
|
|
2072
2317
|
position: "absolute",
|
|
2073
2318
|
width: 0,
|
|
2074
2319
|
height: 0,
|
|
@@ -2086,138 +2331,884 @@ function SiteCodeFrame({ onClient }) {
|
|
|
2086
2331
|
title: "",
|
|
2087
2332
|
"aria-hidden": "true",
|
|
2088
2333
|
tabIndex: -1,
|
|
2089
|
-
style: HIDDEN,
|
|
2334
|
+
style: HIDDEN$1,
|
|
2090
2335
|
...frameProps
|
|
2091
2336
|
});
|
|
2092
2337
|
}
|
|
2093
2338
|
//#endregion
|
|
2094
|
-
//#region src/
|
|
2095
|
-
/**
|
|
2096
|
-
function
|
|
2339
|
+
//#region src/limits-dialog.tsx
|
|
2340
|
+
/** What a change outside someone's limits touches, in words. Files that are none of those are named once, together. */
|
|
2341
|
+
function describeLimitProblems(t, content, problems) {
|
|
2342
|
+
const described = /* @__PURE__ */ new Set();
|
|
2343
|
+
const pages = allPages(content);
|
|
2344
|
+
for (const problem of problems) if (problem.kind === "page") {
|
|
2345
|
+
const title = (pages.find((found) => found.path === problem.address)?.content.data.root.props)?.title;
|
|
2346
|
+
described.add(t("limits.page", { name: typeof title === "string" && title ? title : problem.address }));
|
|
2347
|
+
} else if (problem.kind === "entry") {
|
|
2348
|
+
const collection = content.collections.find((found) => found.id === problem.collection);
|
|
2349
|
+
const title = (collection?.entries.find((found) => found.slug === problem.slug))?.content.fields.title;
|
|
2350
|
+
described.add(t("limits.entry", {
|
|
2351
|
+
name: typeof title === "string" && title ? title : problem.slug,
|
|
2352
|
+
collection: collection?.settings.name ?? problem.collection
|
|
2353
|
+
}));
|
|
2354
|
+
} else described.add(t(`limits.${problem.kind}`));
|
|
2355
|
+
return [...described];
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Changes outside what someone may change: listed, with what they can do
|
|
2359
|
+
* instead, submitting them for review where the site can take one.
|
|
2360
|
+
*/
|
|
2361
|
+
function LimitsDialog({ problems, content, onSubmit, onClose }) {
|
|
2097
2362
|
const t = useStrings();
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2363
|
+
const [state, setState] = useState({ type: "asking" });
|
|
2364
|
+
const submit = async () => {
|
|
2365
|
+
if (!onSubmit) return;
|
|
2366
|
+
setState({ type: "submitting" });
|
|
2367
|
+
try {
|
|
2368
|
+
setState({
|
|
2369
|
+
type: "submitted",
|
|
2370
|
+
...await onSubmit()
|
|
2371
|
+
});
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
setState({
|
|
2374
|
+
type: "error",
|
|
2375
|
+
error
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
};
|
|
2379
|
+
return /* @__PURE__ */ jsxs(Dialog, {
|
|
2380
|
+
title: t("limits.title"),
|
|
2381
|
+
onClose,
|
|
2382
|
+
children: [
|
|
2383
|
+
/* @__PURE__ */ jsx("p", { children: t("limits.intro") }),
|
|
2384
|
+
/* @__PURE__ */ jsx("ul", {
|
|
2385
|
+
className: "gfa-check-list",
|
|
2386
|
+
children: describeLimitProblems(t, content, problems).map((line) => /* @__PURE__ */ jsx("li", { children: line }, line))
|
|
2387
|
+
}),
|
|
2388
|
+
state.type === "submitted" ? /* @__PURE__ */ jsxs("p", {
|
|
2389
|
+
role: "status",
|
|
2390
|
+
children: [
|
|
2391
|
+
t("limits.submitted"),
|
|
2392
|
+
" ",
|
|
2393
|
+
state.url && /* @__PURE__ */ jsx("a", {
|
|
2394
|
+
href: state.url,
|
|
2395
|
+
target: "_blank",
|
|
2396
|
+
rel: "noreferrer",
|
|
2397
|
+
children: t("draft.seeReview")
|
|
2398
|
+
})
|
|
2399
|
+
]
|
|
2400
|
+
}) : /* @__PURE__ */ jsx("p", { children: t(onSubmit ? "limits.review" : "limits.noReview") }),
|
|
2401
|
+
state.type === "error" && /* @__PURE__ */ jsx(ErrorMessage, {
|
|
2402
|
+
message: t("limits.submitError"),
|
|
2403
|
+
error: state.error
|
|
2404
|
+
}),
|
|
2405
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2406
|
+
className: "gfa-dialog-actions",
|
|
2407
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
2408
|
+
onClick: onClose,
|
|
2409
|
+
children: t(state.type === "submitted" ? "action.close" : "limits.goBack")
|
|
2410
|
+
}), onSubmit && state.type !== "submitted" && /* @__PURE__ */ jsx(Button, {
|
|
2411
|
+
variant: "primary",
|
|
2412
|
+
disabled: state.type === "submitting",
|
|
2413
|
+
onClick: () => void submit(),
|
|
2414
|
+
children: t(state.type === "submitting" ? "limits.submitting" : "draft.submit")
|
|
2415
|
+
})]
|
|
2416
|
+
})
|
|
2417
|
+
]
|
|
2102
2418
|
});
|
|
2103
2419
|
}
|
|
2104
2420
|
//#endregion
|
|
2105
|
-
//#region src/
|
|
2421
|
+
//#region src/limits.ts
|
|
2106
2422
|
/**
|
|
2107
|
-
*
|
|
2108
|
-
*
|
|
2109
|
-
*
|
|
2110
|
-
* as version history reads files, at the loaded revision.
|
|
2423
|
+
* What the signed-in person may change, for screens that show the rest
|
|
2424
|
+
* read-only. Publishing checks every change again (`outsideLimits()`), so
|
|
2425
|
+
* these only spare people changes they couldn't publish.
|
|
2111
2426
|
*/
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2427
|
+
/** Whether someone with `limits` may change what's open. Collections' templates are their settings, which limits never cover. */
|
|
2428
|
+
function mayChange(content, limits, what) {
|
|
2429
|
+
if (!limits) return true;
|
|
2430
|
+
switch (what.kind) {
|
|
2431
|
+
case "page": {
|
|
2432
|
+
const page = content.pages.find((found) => found.path === what.path);
|
|
2433
|
+
return mayChangePage(content, limits, page ?? { path: what.path });
|
|
2434
|
+
}
|
|
2435
|
+
case "header":
|
|
2436
|
+
case "footer": return mayChangeLayout(content, limits, what.language);
|
|
2437
|
+
case "template": return false;
|
|
2438
|
+
case "entry": {
|
|
2439
|
+
const entry = content.collections.find((found) => found.id === what.collection)?.entries.find((found) => found.slug === what.entry);
|
|
2440
|
+
return entry ? mayChangeEntry(content, limits, entry) : (limits.collections ?? []).includes(what.collection);
|
|
2441
|
+
}
|
|
2442
|
+
case "media": return mayChangeMedia(limits);
|
|
2443
|
+
case "menus": return mayChangeMenus(content, limits, what.language);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
/** Whether the signed-in person may change what's open, once the site has loaded. */
|
|
2447
|
+
function useMayChange(what) {
|
|
2448
|
+
const { state, limits } = useAdmin();
|
|
2449
|
+
return state.status !== "ready" || mayChange(state.content, limits, what);
|
|
2128
2450
|
}
|
|
2129
2451
|
//#endregion
|
|
2130
|
-
//#region src/
|
|
2131
|
-
|
|
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 = {
|
|
2452
|
+
//#region src/media-previews.ts
|
|
2453
|
+
const TYPES = {
|
|
2144
2454
|
jpg: "image/jpeg",
|
|
2145
2455
|
jpeg: "image/jpeg",
|
|
2146
2456
|
png: "image/png",
|
|
2147
|
-
|
|
2457
|
+
gif: "image/gif",
|
|
2458
|
+
webp: "image/webp",
|
|
2459
|
+
avif: "image/avif",
|
|
2460
|
+
svg: "image/svg+xml",
|
|
2461
|
+
ico: "image/x-icon",
|
|
2462
|
+
pdf: "application/pdf",
|
|
2463
|
+
mp3: "audio/mpeg",
|
|
2464
|
+
m4a: "audio/mp4",
|
|
2465
|
+
mp4: "video/mp4",
|
|
2466
|
+
webm: "video/webm",
|
|
2467
|
+
vtt: "text/vtt"
|
|
2148
2468
|
};
|
|
2149
|
-
|
|
2150
|
-
|
|
2469
|
+
/** The type to show a file as, from its name. Needed so browsers display SVGs from memory. */
|
|
2470
|
+
function mediaType(name) {
|
|
2471
|
+
return TYPES[extensionOf(name)] ?? "application/octet-stream";
|
|
2151
2472
|
}
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
*/
|
|
2157
|
-
|
|
2158
|
-
const
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2473
|
+
function createMediaPreviews(store, siteUrl, storage = () => void 0) {
|
|
2474
|
+
const local = /* @__PURE__ */ new Map();
|
|
2475
|
+
const remembered = /* @__PURE__ */ new Map();
|
|
2476
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2477
|
+
const loading = /* @__PURE__ */ new Map();
|
|
2478
|
+
const base = trimChars(siteUrl, "/", { start: false });
|
|
2479
|
+
const remember = (url, bytes) => {
|
|
2480
|
+
const previous = local.get(url);
|
|
2481
|
+
if (previous) URL.revokeObjectURL(previous);
|
|
2482
|
+
remembered.set(url, bytes);
|
|
2483
|
+
for (const listener of listeners) listener(url, bytes);
|
|
2484
|
+
local.set(url, URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mediaType(url) })));
|
|
2485
|
+
};
|
|
2486
|
+
return {
|
|
2487
|
+
resolve(url) {
|
|
2488
|
+
if (!url.startsWith("/media/")) return url;
|
|
2489
|
+
const end = url.search(/[?#]/);
|
|
2490
|
+
const stored = local.has(url) ? void 0 : storedMediaUrl(storage(), end < 0 ? url : url.slice(0, end));
|
|
2491
|
+
return stored ? `${stored}${end < 0 ? "" : url.slice(end)}` : local.get(url) ?? `${base}${url}`;
|
|
2492
|
+
},
|
|
2493
|
+
remember,
|
|
2494
|
+
onRemember(listener) {
|
|
2495
|
+
for (const [url, bytes] of remembered) listener(url, bytes);
|
|
2496
|
+
listeners.add(listener);
|
|
2497
|
+
return () => listeners.delete(listener);
|
|
2498
|
+
},
|
|
2499
|
+
async bytes(url) {
|
|
2500
|
+
const known = remembered.get(url);
|
|
2501
|
+
if (known) return known;
|
|
2502
|
+
const path = mediaPath(url);
|
|
2503
|
+
return path ? await store.readBytes(path).catch(() => void 0) : void 0;
|
|
2504
|
+
},
|
|
2505
|
+
load(url) {
|
|
2506
|
+
const known = local.get(url);
|
|
2507
|
+
if (known) return Promise.resolve(known);
|
|
2508
|
+
const path = mediaPath(url);
|
|
2509
|
+
if (!path) return Promise.resolve(void 0);
|
|
2510
|
+
let pending = loading.get(url);
|
|
2511
|
+
if (!pending) {
|
|
2512
|
+
pending = store.readBytes(path).then((bytes) => {
|
|
2513
|
+
if (!bytes) return void 0;
|
|
2514
|
+
remember(url, bytes);
|
|
2515
|
+
return local.get(url);
|
|
2516
|
+
}).catch(() => void 0);
|
|
2517
|
+
loading.set(url, pending);
|
|
2518
|
+
}
|
|
2519
|
+
return pending;
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2178
2522
|
}
|
|
2179
|
-
const
|
|
2180
|
-
"
|
|
2181
|
-
"
|
|
2182
|
-
"
|
|
2183
|
-
"embed",
|
|
2184
|
-
"object",
|
|
2185
|
-
"audio",
|
|
2186
|
-
"video"
|
|
2523
|
+
const URL_ATTRIBUTES = [
|
|
2524
|
+
"src",
|
|
2525
|
+
"href",
|
|
2526
|
+
"poster"
|
|
2187
2527
|
];
|
|
2528
|
+
const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
|
|
2188
2529
|
/**
|
|
2189
|
-
*
|
|
2190
|
-
*
|
|
2191
|
-
*
|
|
2530
|
+
* Keeps media in a preview document showing: rewrites `/media/…` addresses in
|
|
2531
|
+
* images, links and inline styles as the preview changes, and if an image
|
|
2532
|
+
* still doesn't load, reads it from the repository instead.
|
|
2192
2533
|
*/
|
|
2193
|
-
function
|
|
2194
|
-
const
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
}
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
}
|
|
2220
|
-
|
|
2534
|
+
function showMediaInPreview(doc, previews) {
|
|
2535
|
+
const fix = (element) => {
|
|
2536
|
+
for (const attribute of URL_ATTRIBUTES) {
|
|
2537
|
+
const value = element.getAttribute(attribute);
|
|
2538
|
+
if (!value?.startsWith("/media/")) continue;
|
|
2539
|
+
const resolved = previews.resolve(value);
|
|
2540
|
+
if (resolved !== value) {
|
|
2541
|
+
element.setAttribute(`data-gf-${attribute}`, value);
|
|
2542
|
+
element.setAttribute(attribute, resolved);
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
const style = element.getAttribute("style");
|
|
2546
|
+
if (style?.includes("/media/")) {
|
|
2547
|
+
const resolved = style.replace(MEDIA_IN_STYLE, (_match, quote, url) => `url(${quote}${previews.resolve(url)}${quote})`);
|
|
2548
|
+
if (resolved !== style) element.setAttribute("style", resolved);
|
|
2549
|
+
}
|
|
2550
|
+
};
|
|
2551
|
+
const fixTree = (root) => {
|
|
2552
|
+
fix(root);
|
|
2553
|
+
for (const element of root.querySelectorAll("[src], [href], [poster], [style]")) fix(element);
|
|
2554
|
+
};
|
|
2555
|
+
const observer = new MutationObserver((records) => {
|
|
2556
|
+
for (const record of records) {
|
|
2557
|
+
if (record.type === "attributes" && record.target.nodeType === 1) fix(record.target);
|
|
2558
|
+
for (const node of record.addedNodes) if (node.nodeType === 1) fixTree(node);
|
|
2559
|
+
}
|
|
2560
|
+
});
|
|
2561
|
+
observer.observe(doc.documentElement, {
|
|
2562
|
+
subtree: true,
|
|
2563
|
+
childList: true,
|
|
2564
|
+
attributes: true,
|
|
2565
|
+
attributeFilter: [...URL_ATTRIBUTES, "style"]
|
|
2566
|
+
});
|
|
2567
|
+
fixTree(doc.documentElement);
|
|
2568
|
+
const onError = (event) => {
|
|
2569
|
+
const image = event.target;
|
|
2570
|
+
if (image?.tagName !== "IMG") return;
|
|
2571
|
+
const original = image.getAttribute("data-gf-src") ?? image.getAttribute("src") ?? "";
|
|
2572
|
+
if (!original.startsWith("/media/") || image.hasAttribute("data-gf-loaded")) return;
|
|
2573
|
+
image.setAttribute("data-gf-loaded", "");
|
|
2574
|
+
previews.load(original).then((url) => {
|
|
2575
|
+
if (url) {
|
|
2576
|
+
image.setAttribute("data-gf-src", original);
|
|
2577
|
+
image.setAttribute("src", url);
|
|
2578
|
+
}
|
|
2579
|
+
});
|
|
2580
|
+
};
|
|
2581
|
+
doc.addEventListener("error", onError, true);
|
|
2582
|
+
return () => {
|
|
2583
|
+
observer.disconnect();
|
|
2584
|
+
doc.removeEventListener("error", onError, true);
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
//#endregion
|
|
2588
|
+
//#region src/plugin-frames.tsx
|
|
2589
|
+
/**
|
|
2590
|
+
* The admin panel's side of plugins' admin frames: making them, connecting to
|
|
2591
|
+
* them, and answering only for what each plugin declared. Everything a frame
|
|
2592
|
+
* sends is treated as the plugin's code may have written it.
|
|
2593
|
+
*/
|
|
2594
|
+
/** A plugin's frame asked for something it didn't declare, or that isn't there. */
|
|
2595
|
+
var RefusedError = class extends Error {
|
|
2596
|
+
name = "RefusedError";
|
|
2597
|
+
};
|
|
2598
|
+
/** How many times a plugin's frame may start again before it's given up on. */
|
|
2599
|
+
const MAX_CONNECTIONS = 5;
|
|
2600
|
+
/** How long a service call waits for the plugin's frame to start. */
|
|
2601
|
+
const START_TIMEOUT = 2e4;
|
|
2602
|
+
function isAdminScreen(href) {
|
|
2603
|
+
return typeof href === "string" && /^#\/[\w\-/?=&%.]*$/.test(href);
|
|
2604
|
+
}
|
|
2605
|
+
function strings(value, what) {
|
|
2606
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new RefusedError(`Say which ${what}.`);
|
|
2607
|
+
return value;
|
|
2608
|
+
}
|
|
2609
|
+
/** A plugin's file change, as the admin panel publishes it, if the plugin declared its file. */
|
|
2610
|
+
function toFileChange(plugin, value) {
|
|
2611
|
+
if (typeof value !== "object" || value === null) throw new RefusedError("That isn't a change.");
|
|
2612
|
+
const change = value;
|
|
2613
|
+
if (typeof change.path !== "string" || !pluginMayUse(plugin.permissions, change.path)) throw new RefusedError(`${plugin.title} didn't ask to change ${String(change.path)}.`);
|
|
2614
|
+
if (change.delete === true) return {
|
|
2615
|
+
path: change.path,
|
|
2616
|
+
delete: true
|
|
2617
|
+
};
|
|
2618
|
+
if (typeof change.text === "string") return {
|
|
2619
|
+
path: change.path,
|
|
2620
|
+
content: change.text
|
|
2621
|
+
};
|
|
2622
|
+
if (change.bytes instanceof Uint8Array) return {
|
|
2623
|
+
path: change.path,
|
|
2624
|
+
bytes: change.bytes
|
|
2625
|
+
};
|
|
2626
|
+
throw new RefusedError("That isn't a change.");
|
|
2627
|
+
}
|
|
2628
|
+
/** The tallest a screen's frame is made, whatever it says. */
|
|
2629
|
+
const MAX_SCREEN_HEIGHT = 2e4;
|
|
2630
|
+
const MEDIA_KINDS = /* @__PURE__ */ new Set([
|
|
2631
|
+
"image",
|
|
2632
|
+
"video",
|
|
2633
|
+
"audio",
|
|
2634
|
+
"file"
|
|
2635
|
+
]);
|
|
2636
|
+
/** The answers a plugin's frame gets, each checked against what the plugin declared. */
|
|
2637
|
+
function pluginHandlers(plugin, host, onResize, editor) {
|
|
2638
|
+
/** The page edited with the plugin's editor, for requests only its editor frame may make. */
|
|
2639
|
+
const editing = (what) => {
|
|
2640
|
+
const hooks = editor();
|
|
2641
|
+
if (!hooks) throw new RefusedError(`Only an editor's frame may ${what}.`);
|
|
2642
|
+
return hooks;
|
|
2643
|
+
};
|
|
2644
|
+
const secretsLinks = /* @__PURE__ */ new Set();
|
|
2645
|
+
return {
|
|
2646
|
+
read: async (params) => {
|
|
2647
|
+
const paths = strings(params?.paths, "files");
|
|
2648
|
+
const refused = paths.find((path) => !pluginMayUse(plugin.permissions, path));
|
|
2649
|
+
if (refused) throw new RefusedError(`${plugin.title} didn't ask to read ${refused}.`);
|
|
2650
|
+
const read = await Promise.all(paths.map(async (path) => [path, await host().read(path) ?? null]));
|
|
2651
|
+
return Object.fromEntries(read);
|
|
2652
|
+
},
|
|
2653
|
+
readBytes: async (params) => {
|
|
2654
|
+
const path = params?.path;
|
|
2655
|
+
if (typeof path !== "string" || !pluginMayUse(plugin.permissions, path)) throw new RefusedError(`${plugin.title} didn't ask to read ${String(path)}.`);
|
|
2656
|
+
return await host().readBytes(path) ?? null;
|
|
2657
|
+
},
|
|
2658
|
+
list: async (params) => {
|
|
2659
|
+
const folder = params?.folder;
|
|
2660
|
+
if (typeof folder !== "string" || !pluginMayList(plugin.permissions, folder)) throw new RefusedError(`${plugin.title} didn't ask to list ${String(folder)}.`);
|
|
2661
|
+
return (await host().list(folder.replace(/\/+$/, ""))).filter((path) => pluginMayUse(plugin.permissions, path));
|
|
2662
|
+
},
|
|
2663
|
+
publish: async (params) => {
|
|
2664
|
+
if (!Array.isArray(params?.changes) || params.changes.length === 0 || typeof params.message !== "string") throw new RefusedError("That isn't a publish.");
|
|
2665
|
+
const changes = params.changes.map((change) => toFileChange(plugin, change));
|
|
2666
|
+
return { published: await host().publish(changes, params.message.slice(0, 500)) };
|
|
2667
|
+
},
|
|
2668
|
+
server: async (params) => {
|
|
2669
|
+
const base = host().server;
|
|
2670
|
+
if (!plugin.server || !base) throw new RefusedError(`${plugin.title} has no server here.`);
|
|
2671
|
+
const path = params?.path;
|
|
2672
|
+
if (typeof path !== "string" || path.includes("..") || /^[a-z]+:/i.test(path)) throw new RefusedError("That isn't one of the plugin's addresses.");
|
|
2673
|
+
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
|
2674
|
+
const headers = Object.fromEntries(Object.entries(params.headers ?? {}).filter(([name, value]) => typeof value === "string" && /^(content-type|accept|x-goodfellow-plugin-[\w-]+)$/i.test(name)));
|
|
2675
|
+
const response = await fetch(`${base.replace(/\/+$/, "")}/${encodeURIComponent(plugin.id)}/${path.replace(/^\/+/, "")}`, {
|
|
2676
|
+
method,
|
|
2677
|
+
headers: {
|
|
2678
|
+
...headers,
|
|
2679
|
+
[APP_API_HEADER]: "1"
|
|
2680
|
+
},
|
|
2681
|
+
...typeof params.body === "string" && method !== "GET" && method !== "HEAD" && { body: params.body },
|
|
2682
|
+
credentials: "same-origin",
|
|
2683
|
+
cache: "no-store"
|
|
2684
|
+
});
|
|
2685
|
+
return {
|
|
2686
|
+
status: response.status,
|
|
2687
|
+
body: await response.text()
|
|
2688
|
+
};
|
|
2689
|
+
},
|
|
2690
|
+
navigate: (href) => {
|
|
2691
|
+
if (isAdminScreen(href)) host().navigate(href);
|
|
2692
|
+
},
|
|
2693
|
+
"secrets.help": async () => {
|
|
2694
|
+
const help = await host().secretsHelp?.(plugin) ?? [];
|
|
2695
|
+
for (const { link } of help) if (link) secretsLinks.add(link.url);
|
|
2696
|
+
return help;
|
|
2697
|
+
},
|
|
2698
|
+
"secrets.open": (params) => {
|
|
2699
|
+
const url = params?.url;
|
|
2700
|
+
if (typeof url !== "string" || !secretsLinks.has(url)) throw new RefusedError("That isn't one of the links given.");
|
|
2701
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
2702
|
+
},
|
|
2703
|
+
resize: (params) => {
|
|
2704
|
+
const height = params?.height;
|
|
2705
|
+
if (typeof height === "number" && Number.isFinite(height)) onResize(Math.min(Math.max(0, Math.ceil(height)), MAX_SCREEN_HEIGHT));
|
|
2706
|
+
},
|
|
2707
|
+
"editor.change": (params) => {
|
|
2708
|
+
const hooks = editing("say what's edited");
|
|
2709
|
+
if (typeof params !== "object" || params === null || typeof params.data !== "object" || params.data === null) throw new RefusedError("That isn't what's edited.");
|
|
2710
|
+
const state = typeof params.state === "object" && params.state !== null ? params.state : {};
|
|
2711
|
+
hooks.onChange({
|
|
2712
|
+
data: params.data,
|
|
2713
|
+
state: {
|
|
2714
|
+
past: state.past === true,
|
|
2715
|
+
future: state.future === true,
|
|
2716
|
+
selected: typeof state.selected === "string" ? state.selected : null
|
|
2717
|
+
}
|
|
2718
|
+
});
|
|
2719
|
+
},
|
|
2720
|
+
"media.choose": (params) => {
|
|
2721
|
+
const hooks = editing("choose media");
|
|
2722
|
+
const kind = params?.kind;
|
|
2723
|
+
if (typeof kind !== "string" || !MEDIA_KINDS.has(kind)) throw new RefusedError("That isn't a kind of media.");
|
|
2724
|
+
return hooks.chooseMedia(kind, typeof params.value === "string" ? params.value : "");
|
|
2725
|
+
},
|
|
2726
|
+
"editor.sharePreview": (params) => {
|
|
2727
|
+
return editing("preview a shared link").sharePreview(params?.root);
|
|
2728
|
+
},
|
|
2729
|
+
"editor.draw": (params) => {
|
|
2730
|
+
const hooks = editing("draw blocks");
|
|
2731
|
+
const block = params?.block;
|
|
2732
|
+
if (typeof block !== "object" || block === null || typeof block.type !== "string" || typeof block.props !== "object" || block.props === null) throw new RefusedError("That isn't a block.");
|
|
2733
|
+
return hooks.draw(block);
|
|
2734
|
+
},
|
|
2735
|
+
"editor.styles": () => editing("read the site's styles").styles(),
|
|
2736
|
+
task: async (params) => {
|
|
2737
|
+
const id = params?.id;
|
|
2738
|
+
if (typeof id !== "string" || !plugin.tasks?.some((task) => task.id === id)) throw new RefusedError(`${plugin.title} has no task called ${String(id)}.`);
|
|
2739
|
+
const run = host().runTask;
|
|
2740
|
+
if (!run) throw new RefusedError(`${plugin.title}'s tasks can't run here.`);
|
|
2741
|
+
let input;
|
|
2742
|
+
try {
|
|
2743
|
+
input = JSON.stringify(params.input ?? null);
|
|
2744
|
+
} catch {
|
|
2745
|
+
throw new RefusedError("A task's input must be plain data.");
|
|
2746
|
+
}
|
|
2747
|
+
if (input.length > TASK_INPUT_LIMIT) throw new RefusedError("That's too much for one task.");
|
|
2748
|
+
const keys = await taskKeys();
|
|
2749
|
+
const sealed = await run({
|
|
2750
|
+
id: newTaskRequestId(),
|
|
2751
|
+
plugin: plugin.id,
|
|
2752
|
+
task: id,
|
|
2753
|
+
input,
|
|
2754
|
+
key: keys.publicKey
|
|
2755
|
+
});
|
|
2756
|
+
const answer = await openTaskAnswer(sealed, keys.privateKey);
|
|
2757
|
+
if (!answer.ok) throw new Error(answer.error.slice(0, 2e3));
|
|
2758
|
+
return answer.output;
|
|
2759
|
+
}
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
/** Makes one of a plugin's frames and connects to it; an editor's frame with `editor`. */
|
|
2763
|
+
function usePluginFrame(plugin, host, mode, screen, editor) {
|
|
2764
|
+
const element = useRef(null);
|
|
2765
|
+
const [channel, setChannel] = useState();
|
|
2766
|
+
const [failed, setFailed] = useState(false);
|
|
2767
|
+
const [height, setHeight] = useState();
|
|
2768
|
+
const latest = useRef(host);
|
|
2769
|
+
latest.current = host;
|
|
2770
|
+
const latestEditor = useRef(editor);
|
|
2771
|
+
latestEditor.current = editor;
|
|
2772
|
+
useEffect(() => {
|
|
2773
|
+
let current;
|
|
2774
|
+
let connections = 0;
|
|
2775
|
+
let stopped = false;
|
|
2776
|
+
const onMessage = (event) => {
|
|
2777
|
+
const frame = element.current?.contentWindow;
|
|
2778
|
+
if (!frame || event.source !== frame || event.data !== "goodfellow:plugin-ready") return;
|
|
2779
|
+
current?.close();
|
|
2780
|
+
setChannel(void 0);
|
|
2781
|
+
connections += 1;
|
|
2782
|
+
if (connections > MAX_CONNECTIONS) {
|
|
2783
|
+
setFailed(true);
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
const { port1, port2 } = new MessageChannel();
|
|
2787
|
+
const next = createChannel(port1, pluginHandlers(plugin, () => latest.current, (next) => {
|
|
2788
|
+
if (mode === "screen") setHeight(next);
|
|
2789
|
+
}, () => mode === "editor" ? latestEditor.current : void 0));
|
|
2790
|
+
current = next;
|
|
2791
|
+
frame.postMessage({ type: CONNECT_MESSAGE }, "*", [port2]);
|
|
2792
|
+
const opening = mode === "editor" ? latestEditor.current?.open() : void 0;
|
|
2793
|
+
const init = {
|
|
2794
|
+
mode,
|
|
2795
|
+
...screen && { screen },
|
|
2796
|
+
...opening && { editor: opening },
|
|
2797
|
+
plugin,
|
|
2798
|
+
siteUrl: latest.current.siteUrl,
|
|
2799
|
+
theme: latest.current.theme
|
|
2800
|
+
};
|
|
2801
|
+
next.call("init", init).then((opened) => {
|
|
2802
|
+
if (stopped || current !== next) return;
|
|
2803
|
+
setChannel(next);
|
|
2804
|
+
if (mode === "editor") {
|
|
2805
|
+
const said = opened ?? {};
|
|
2806
|
+
latestEditor.current?.onOpened({
|
|
2807
|
+
set: said.set === true,
|
|
2808
|
+
undo: said.undo === true
|
|
2809
|
+
}, next);
|
|
2810
|
+
}
|
|
2811
|
+
}, () => {
|
|
2812
|
+
if (!stopped && current === next) setFailed(true);
|
|
2813
|
+
});
|
|
2814
|
+
};
|
|
2815
|
+
window.addEventListener("message", onMessage);
|
|
2816
|
+
return () => {
|
|
2817
|
+
stopped = true;
|
|
2818
|
+
window.removeEventListener("message", onMessage);
|
|
2819
|
+
current?.close();
|
|
2820
|
+
};
|
|
2821
|
+
}, [
|
|
2822
|
+
plugin,
|
|
2823
|
+
mode,
|
|
2824
|
+
screen
|
|
2825
|
+
]);
|
|
2826
|
+
useEffect(() => {
|
|
2827
|
+
channel?.call("theme", host.theme).catch(() => {});
|
|
2828
|
+
}, [channel, host.theme]);
|
|
2829
|
+
return {
|
|
2830
|
+
channel,
|
|
2831
|
+
failed,
|
|
2832
|
+
frameProps: {
|
|
2833
|
+
ref: useCallback((frame) => {
|
|
2834
|
+
element.current = frame;
|
|
2835
|
+
}, []),
|
|
2836
|
+
sandbox: FRAME_SANDBOX,
|
|
2837
|
+
src: pluginFrameUrl(host.editorUrl, plugin.id),
|
|
2838
|
+
...height !== void 0 && { style: { height } }
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
const HIDDEN = {
|
|
2843
|
+
position: "absolute",
|
|
2844
|
+
width: 0,
|
|
2845
|
+
height: 0,
|
|
2846
|
+
border: 0,
|
|
2847
|
+
visibility: "hidden"
|
|
2848
|
+
};
|
|
2849
|
+
/** One plugin's hidden frame for its services, which tells `onChannel` once it has started. */
|
|
2850
|
+
function ServiceFrame({ plugin, host, onChannel }) {
|
|
2851
|
+
const { channel, frameProps } = usePluginFrame(plugin, host, "service");
|
|
2852
|
+
useEffect(() => onChannel(plugin.id, channel), [
|
|
2853
|
+
plugin.id,
|
|
2854
|
+
channel,
|
|
2855
|
+
onChannel
|
|
2856
|
+
]);
|
|
2857
|
+
return /* @__PURE__ */ jsx("iframe", {
|
|
2858
|
+
title: "",
|
|
2859
|
+
"aria-hidden": "true",
|
|
2860
|
+
tabIndex: -1,
|
|
2861
|
+
...frameProps,
|
|
2862
|
+
style: HIDDEN
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
const PluginHostContext = createContext(null);
|
|
2866
|
+
const PluginMediaContext = createContext(null);
|
|
2867
|
+
/**
|
|
2868
|
+
* The plugin keeping the site's media, by its name in
|
|
2869
|
+
* `content/media-storage.json`, where the site has it and it says it keeps
|
|
2870
|
+
* media. Outside `PluginServices`, such as in the site's frames, none.
|
|
2871
|
+
*/
|
|
2872
|
+
function usePluginMedia(name) {
|
|
2873
|
+
const media = useContext(PluginMediaContext);
|
|
2874
|
+
return name && media ? media(name) : void 0;
|
|
2875
|
+
}
|
|
2876
|
+
/** What a plugin's frame said about being ready, as the admin panel shows it: its message is the plugin's own words. */
|
|
2877
|
+
function readyAnswer(plugin, value) {
|
|
2878
|
+
const answer = value ?? {};
|
|
2879
|
+
if (answer.ready === true) return { ready: true };
|
|
2880
|
+
const screen = typeof answer.screen === "string" && plugin.screens?.some((own) => own.id === answer.screen) ? answer.screen : void 0;
|
|
2881
|
+
return {
|
|
2882
|
+
ready: false,
|
|
2883
|
+
message: typeof answer.message === "string" ? answer.message.slice(0, 500) : "",
|
|
2884
|
+
...screen && { screen }
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
/** What plugins' screens are given, inside `PluginServices`. */
|
|
2888
|
+
function usePluginHost() {
|
|
2889
|
+
const value = useContext(PluginHostContext);
|
|
2890
|
+
if (!value) throw new Error("Plugins' frames need the admin panel around them.");
|
|
2891
|
+
return value;
|
|
2892
|
+
}
|
|
2893
|
+
/**
|
|
2894
|
+
* Plugins' services for the editor's blocks: each plugin's hidden frame is
|
|
2895
|
+
* made the first time something asks it. `onCall` gets the function that
|
|
2896
|
+
* asks, `(plugin name, method, arguments)`.
|
|
2897
|
+
*/
|
|
2898
|
+
function PluginServices({ plugins, host, onCall, children }) {
|
|
2899
|
+
const [started, setStarted] = useState([]);
|
|
2900
|
+
const ready = useRef(/* @__PURE__ */ new Map());
|
|
2901
|
+
const waiting = useRef(/* @__PURE__ */ new Map());
|
|
2902
|
+
const onChannel = useCallback((id, channel) => {
|
|
2903
|
+
if (!channel) {
|
|
2904
|
+
ready.current.delete(id);
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
ready.current.set(id, channel);
|
|
2908
|
+
for (const resolve of waiting.current.get(id)?.splice(0) ?? []) resolve(channel);
|
|
2909
|
+
}, []);
|
|
2910
|
+
const channelFor = useCallback(async (plugin) => {
|
|
2911
|
+
const channel = ready.current.get(plugin.id);
|
|
2912
|
+
if (channel) return channel;
|
|
2913
|
+
setStarted((current) => current.includes(plugin.id) ? current : [...current, plugin.id]);
|
|
2914
|
+
return await new Promise((resolve, reject) => {
|
|
2915
|
+
const list = waiting.current.get(plugin.id) ?? [];
|
|
2916
|
+
list.push(resolve);
|
|
2917
|
+
waiting.current.set(plugin.id, list);
|
|
2918
|
+
setTimeout(() => reject(/* @__PURE__ */ new Error(`${plugin.title} didn't start.`)), START_TIMEOUT);
|
|
2919
|
+
});
|
|
2920
|
+
}, []);
|
|
2921
|
+
const call = useCallback(async (name, method, args) => {
|
|
2922
|
+
const plugin = plugins.find((candidate) => candidate.name === name && candidate.admin);
|
|
2923
|
+
if (!plugin) throw new Error(`The site has no plugin called ${name} with services.`);
|
|
2924
|
+
return (await channelFor(plugin)).call("call", {
|
|
2925
|
+
method,
|
|
2926
|
+
args
|
|
2927
|
+
});
|
|
2928
|
+
}, [plugins, channelFor]);
|
|
2929
|
+
useEffect(() => onCall(call), [call, onCall]);
|
|
2930
|
+
const media = useCallback((name) => {
|
|
2931
|
+
const plugin = plugins.find((candidate) => candidate.name === name && candidate.admin && candidate.mediaStorage);
|
|
2932
|
+
if (!plugin) return void 0;
|
|
2933
|
+
return {
|
|
2934
|
+
plugin,
|
|
2935
|
+
ready: async () => readyAnswer(plugin, await (await channelFor(plugin)).call("media.ready")),
|
|
2936
|
+
upload: async (files) => {
|
|
2937
|
+
await (await channelFor(plugin)).call("media.upload", { files });
|
|
2938
|
+
},
|
|
2939
|
+
remove: async (addresses) => {
|
|
2940
|
+
await (await channelFor(plugin)).call("media.remove", { addresses });
|
|
2941
|
+
}
|
|
2942
|
+
};
|
|
2943
|
+
}, [plugins, channelFor]);
|
|
2944
|
+
return /* @__PURE__ */ jsxs(PluginHostContext.Provider, {
|
|
2945
|
+
value: host,
|
|
2946
|
+
children: [/* @__PURE__ */ jsx(PluginMediaContext.Provider, {
|
|
2947
|
+
value: media,
|
|
2948
|
+
children
|
|
2949
|
+
}), plugins.filter((plugin) => started.includes(plugin.id)).map((plugin) => /* @__PURE__ */ jsx(ServiceFrame, {
|
|
2950
|
+
plugin,
|
|
2951
|
+
host,
|
|
2952
|
+
onChannel
|
|
2953
|
+
}, plugin.id))]
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
/** One of a plugin's screens, drawn by its admin module in a frame of its own. */
|
|
2957
|
+
function PluginScreenFrame({ plugin, screen, failedText }) {
|
|
2958
|
+
const { failed, frameProps } = usePluginFrame(plugin, usePluginHost(), "screen", screen.id);
|
|
2959
|
+
if (failed) return /* @__PURE__ */ jsx("p", {
|
|
2960
|
+
className: "gfa-error",
|
|
2961
|
+
children: failedText
|
|
2962
|
+
});
|
|
2963
|
+
return /* @__PURE__ */ jsx("iframe", {
|
|
2964
|
+
title: screen.title,
|
|
2965
|
+
className: "gfa-plugin-screen",
|
|
2966
|
+
...frameProps
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
//#endregion
|
|
2970
|
+
//#region src/publish-failure.tsx
|
|
2971
|
+
/** Explains why a publish from a dialog or form didn't go through. */
|
|
2972
|
+
function PublishFailure({ failure }) {
|
|
2973
|
+
const t = useStrings();
|
|
2974
|
+
if (!failure || failure.reason === "checks") return null;
|
|
2975
|
+
return /* @__PURE__ */ jsx(ErrorMessage, {
|
|
2976
|
+
message: t(failure.reason === "conflict" ? "publish.conflict" : "publish.error"),
|
|
2977
|
+
error: failure.error
|
|
2978
|
+
});
|
|
2979
|
+
}
|
|
2980
|
+
//#endregion
|
|
2981
|
+
//#region src/site-host.ts
|
|
2982
|
+
/**
|
|
2983
|
+
* The host outside GitHub, GitLab and Bitbucket that builds the site, as its setup files
|
|
2984
|
+
* say, such as Cloudflare Pages: `undefined` while they're read, `null` for none.
|
|
2985
|
+
* They're outside the folders the site's files are read from, so they're read
|
|
2986
|
+
* as version history reads files, at the loaded revision.
|
|
2987
|
+
*/
|
|
2988
|
+
function useOutsideHost() {
|
|
2989
|
+
const { readFile, versions, state } = useAdmin();
|
|
2990
|
+
const revision = state.status === "ready" ? state.revision : void 0;
|
|
2991
|
+
const [host, setHost] = useState();
|
|
2992
|
+
useEffect(() => {
|
|
2993
|
+
let cancelled = false;
|
|
2994
|
+
outsideHost(versions && revision ? (path) => versions.readAt(path, revision) : readFile).then((found) => !cancelled && setHost(found ?? null), () => !cancelled && setHost(null));
|
|
2995
|
+
return () => {
|
|
2996
|
+
cancelled = true;
|
|
2997
|
+
};
|
|
2998
|
+
}, [
|
|
2999
|
+
readFile,
|
|
3000
|
+
versions,
|
|
3001
|
+
revision
|
|
3002
|
+
]);
|
|
3003
|
+
return host;
|
|
3004
|
+
}
|
|
3005
|
+
//#endregion
|
|
3006
|
+
//#region src/upload.ts
|
|
3007
|
+
/** Photos are made no larger than this on their longest side, which is plenty for any screen. */
|
|
3008
|
+
const MAX_IMAGE_SIDE = 2400;
|
|
3009
|
+
var UploadError = class extends Error {
|
|
3010
|
+
problem;
|
|
3011
|
+
file;
|
|
3012
|
+
name = "UploadError";
|
|
3013
|
+
constructor(problem, file, message) {
|
|
3014
|
+
super(message);
|
|
3015
|
+
this.problem = problem;
|
|
3016
|
+
this.file = file;
|
|
3017
|
+
}
|
|
3018
|
+
};
|
|
3019
|
+
const REENCODED = {
|
|
3020
|
+
jpg: "image/jpeg",
|
|
3021
|
+
jpeg: "image/jpeg",
|
|
3022
|
+
png: "image/png",
|
|
3023
|
+
webp: "image/webp"
|
|
3024
|
+
};
|
|
3025
|
+
async function canvasBlob(canvas, type) {
|
|
3026
|
+
return new Promise((resolve) => canvas.toBlob(resolve, type, .85));
|
|
3027
|
+
}
|
|
3028
|
+
/**
|
|
3029
|
+
* Re-saves a photo: smaller if it's larger than any screen needs, and always
|
|
3030
|
+
* for JPEGs, which drops details hidden in the file such as where it was taken.
|
|
3031
|
+
* Returns `undefined` to keep the file as it is.
|
|
3032
|
+
*/
|
|
3033
|
+
async function resized(file, extension) {
|
|
3034
|
+
const type = REENCODED[extension];
|
|
3035
|
+
if (!type || typeof createImageBitmap !== "function") return void 0;
|
|
3036
|
+
let bitmap;
|
|
3037
|
+
try {
|
|
3038
|
+
bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
3039
|
+
} catch {
|
|
3040
|
+
throw new UploadError("unreadable", file.name, `${file.name} couldn't be read as an image.`);
|
|
3041
|
+
}
|
|
3042
|
+
const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
|
|
3043
|
+
if (scale === 1 && type !== "image/jpeg") {
|
|
3044
|
+
bitmap.close();
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
const canvas = document.createElement("canvas");
|
|
3048
|
+
canvas.width = Math.round(bitmap.width * scale);
|
|
3049
|
+
canvas.height = Math.round(bitmap.height * scale);
|
|
3050
|
+
canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
3051
|
+
bitmap.close();
|
|
3052
|
+
const blob = await canvasBlob(canvas, type);
|
|
3053
|
+
return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
|
|
3054
|
+
}
|
|
3055
|
+
const UNSAFE_SVG_ELEMENTS = [
|
|
3056
|
+
"script",
|
|
3057
|
+
"foreignObject",
|
|
3058
|
+
"iframe",
|
|
3059
|
+
"embed",
|
|
3060
|
+
"object",
|
|
3061
|
+
"audio",
|
|
3062
|
+
"video"
|
|
3063
|
+
];
|
|
3064
|
+
/**
|
|
3065
|
+
* Removes anything from an SVG that could run code if someone opens the
|
|
3066
|
+
* file directly: scripts, embedded pages, event handlers, and javascript:, vbscript:
|
|
3067
|
+
* and data: links other than images.
|
|
3068
|
+
*/
|
|
3069
|
+
function cleanSvg(text) {
|
|
3070
|
+
const doc = new DOMParser().parseFromString(text, "image/svg+xml");
|
|
3071
|
+
if (doc.querySelector("parsererror") || doc.documentElement.nodeName.toLowerCase() !== "svg") return void 0;
|
|
3072
|
+
for (const name of UNSAFE_SVG_ELEMENTS) for (const element of [...doc.getElementsByTagName(name)]) element.remove();
|
|
3073
|
+
for (const element of [doc.documentElement, ...doc.documentElement.querySelectorAll("*")]) for (const attribute of [...element.attributes]) {
|
|
3074
|
+
const value = attribute.value.trim().toLowerCase();
|
|
3075
|
+
if (attribute.name.toLowerCase().startsWith("on") || value.startsWith("javascript:") || value.startsWith("vbscript:") || value.startsWith("data:") && !value.startsWith("data:image/")) element.removeAttribute(attribute.name);
|
|
3076
|
+
}
|
|
3077
|
+
return new XMLSerializer().serializeToString(doc);
|
|
3078
|
+
}
|
|
3079
|
+
/** A file checked and made ready, as a `Blob`: photos made web-sized, SVGs made safe, other files as they are. */
|
|
3080
|
+
async function prepared(file, maxBytes) {
|
|
3081
|
+
const extension = extensionOf(file.name);
|
|
3082
|
+
if (!mediaKind$1(file.name)) throw new UploadError("type", file.name, `${file.name} isn't a kind of file the site can use.`);
|
|
3083
|
+
let blob = file;
|
|
3084
|
+
if (extension === "svg") {
|
|
3085
|
+
const cleaned = cleanSvg(await file.text());
|
|
3086
|
+
if (cleaned === void 0) throw new UploadError("unreadable", file.name, `${file.name} isn't a valid SVG image.`);
|
|
3087
|
+
blob = new Blob([cleaned], { type: "image/svg+xml" });
|
|
3088
|
+
} else if (mediaKind$1(file.name) === "image") blob = await resized(file, extension) ?? file;
|
|
3089
|
+
if (blob.size > maxBytes) throw new UploadError("size", file.name, `${file.name} is ${blob.size} bytes, over the ${maxBytes}-byte limit.`);
|
|
3090
|
+
return blob;
|
|
3091
|
+
}
|
|
3092
|
+
/** Checks a file chosen for upload and gets it ready: photos made web-sized, SVGs made safe. */
|
|
3093
|
+
async function prepareUpload(file) {
|
|
3094
|
+
const blob = await prepared(file, MAX_UPLOAD_BYTES);
|
|
3095
|
+
return {
|
|
3096
|
+
name: file.name,
|
|
3097
|
+
bytes: new Uint8Array(await blob.arrayBuffer()),
|
|
3098
|
+
type: blob.type || file.type
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
/** Reads a picture's size in pixels, or nothing where the browser can't. */
|
|
3102
|
+
async function imageSize(blob, name) {
|
|
3103
|
+
if (mediaKind$1(name) !== "image" || extensionOf(name) === "svg" || typeof createImageBitmap !== "function") return;
|
|
3104
|
+
try {
|
|
3105
|
+
const bitmap = await createImageBitmap(blob);
|
|
3106
|
+
const size = {
|
|
3107
|
+
width: bitmap.width,
|
|
3108
|
+
height: bitmap.height
|
|
3109
|
+
};
|
|
3110
|
+
bitmap.close();
|
|
3111
|
+
return size.width > 0 && size.height > 0 ? size : void 0;
|
|
3112
|
+
} catch {
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
/**
|
|
3117
|
+
* Checks a file for media storage and gets it ready as `prepareUpload()`
|
|
3118
|
+
* does, up to the storage's largest file rather than the site's own 25 MB.
|
|
3119
|
+
*/
|
|
3120
|
+
async function prepareStoredUpload(file, maxBytes) {
|
|
3121
|
+
const blob = await prepared(file, maxBytes);
|
|
3122
|
+
const type = blob.type || file.type || mediaType(file.name);
|
|
3123
|
+
return {
|
|
3124
|
+
name: file.name,
|
|
3125
|
+
blob,
|
|
3126
|
+
type,
|
|
3127
|
+
...await imageSize(blob, file.name)
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
//#endregion
|
|
3131
|
+
//#region src/router.tsx
|
|
3132
|
+
function parseHash(hash) {
|
|
3133
|
+
const [path = "", query = ""] = hash.replace(/^#\/?/, "").split("?");
|
|
3134
|
+
return {
|
|
3135
|
+
segments: path.split("/").filter(Boolean),
|
|
3136
|
+
params: new URLSearchParams(query)
|
|
3137
|
+
};
|
|
3138
|
+
}
|
|
3139
|
+
function subscribe$1(onChange) {
|
|
3140
|
+
window.addEventListener("hashchange", onChange);
|
|
3141
|
+
return () => window.removeEventListener("hashchange", onChange);
|
|
3142
|
+
}
|
|
3143
|
+
function useRoute() {
|
|
3144
|
+
return parseHash(useSyncExternalStore(subscribe$1, () => window.location.hash, () => ""));
|
|
3145
|
+
}
|
|
3146
|
+
const guards = /* @__PURE__ */ new Set();
|
|
3147
|
+
/** Whether any screen has unpublished changes. */
|
|
3148
|
+
function hasUnsavedChanges() {
|
|
3149
|
+
return [...guards].some((isDirty) => isDirty());
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Registers a screen's unpublished changes, so leaving it (inside the admin
|
|
3153
|
+
* panel or by closing the tab) asks for confirmation first.
|
|
3154
|
+
*/
|
|
3155
|
+
function useUnsavedChanges(isDirty) {
|
|
3156
|
+
useEffect(() => {
|
|
3157
|
+
if (!isDirty) return;
|
|
3158
|
+
const guard = () => true;
|
|
3159
|
+
guards.add(guard);
|
|
3160
|
+
const onBeforeUnload = (event) => event.preventDefault();
|
|
3161
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
3162
|
+
return () => {
|
|
3163
|
+
guards.delete(guard);
|
|
3164
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
3165
|
+
};
|
|
3166
|
+
}, [isDirty]);
|
|
3167
|
+
}
|
|
3168
|
+
/** Goes to another screen, asking first if there are unpublished changes. Returns whether it navigated. */
|
|
3169
|
+
function navigate(to, confirmLeave) {
|
|
3170
|
+
if (hasUnsavedChanges() && !confirmLeave()) return false;
|
|
3171
|
+
guards.clear();
|
|
3172
|
+
window.location.hash = to;
|
|
3173
|
+
return true;
|
|
3174
|
+
}
|
|
3175
|
+
/** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
|
|
3176
|
+
const SETTINGS_SECTIONS = {
|
|
3177
|
+
contact: "contact",
|
|
3178
|
+
forms: "forms"
|
|
3179
|
+
};
|
|
3180
|
+
function pageEditorHref(path) {
|
|
3181
|
+
return `#/pages/edit?path=${encodeURIComponent(path)}`;
|
|
3182
|
+
}
|
|
3183
|
+
/** An in-app link that asks before leaving a screen with unpublished changes. */
|
|
3184
|
+
function Link({ href, confirmLeave, ...props }) {
|
|
3185
|
+
return /* @__PURE__ */ jsx("a", {
|
|
3186
|
+
...props,
|
|
3187
|
+
href,
|
|
3188
|
+
onClick: (event) => {
|
|
3189
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;
|
|
3190
|
+
event.preventDefault();
|
|
3191
|
+
navigate(href, confirmLeave);
|
|
3192
|
+
}
|
|
3193
|
+
});
|
|
3194
|
+
}
|
|
3195
|
+
function collectionHref(id, tab = "entries") {
|
|
3196
|
+
return tab === "entries" ? `#/collections/${id}` : `#/collections/${id}/${tab}`;
|
|
3197
|
+
}
|
|
3198
|
+
function entryEditorHref(collection, slug) {
|
|
3199
|
+
return `#/collections/${collection}/edit?slug=${encodeURIComponent(slug)}`;
|
|
3200
|
+
}
|
|
3201
|
+
//#endregion
|
|
3202
|
+
//#region src/use-link.tsx
|
|
3203
|
+
/** `Link`, with the standard "leave without publishing?" confirmation. */
|
|
3204
|
+
function AppLink(props) {
|
|
3205
|
+
const t = useStrings();
|
|
3206
|
+
return /* @__PURE__ */ jsx(Link, {
|
|
3207
|
+
...props,
|
|
3208
|
+
confirmLeave: () => window.confirm(t("unsaved.confirm"))
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
//#endregion
|
|
2221
3212
|
//#region src/media-library.tsx
|
|
2222
3213
|
/** A media file's picture: the image itself, or its kind of file for anything else. */
|
|
2223
3214
|
function MediaThumb({ url, className }) {
|
|
@@ -2268,16 +3259,148 @@ const uploadProblems = {
|
|
|
2268
3259
|
size: "media.error.size",
|
|
2269
3260
|
unreadable: "media.error.unreadable"
|
|
2270
3261
|
};
|
|
3262
|
+
/** The plugin keeping the site's media, where the site has one and it's there to ask. */
|
|
3263
|
+
function useMediaStorage() {
|
|
3264
|
+
const { content } = useSiteContent();
|
|
3265
|
+
const storage = content.mediaStorage;
|
|
3266
|
+
const client = usePluginMedia(storage?.plugin);
|
|
3267
|
+
return {
|
|
3268
|
+
storage,
|
|
3269
|
+
...client && { client }
|
|
3270
|
+
};
|
|
3271
|
+
}
|
|
3272
|
+
/** How many files moving media hands the plugin at once. */
|
|
3273
|
+
const MOVE_BATCH = 20;
|
|
3274
|
+
/** Whether the storage plugin can take files now, or why not, with a link to where that's fixed. */
|
|
3275
|
+
async function storageReady(t, client, tasks) {
|
|
3276
|
+
const { plugin } = client;
|
|
3277
|
+
if (plugin.tasks?.length && !await tasks?.supported().catch(() => false)) return {
|
|
3278
|
+
ok: false,
|
|
3279
|
+
message: t("plugins.task.missing")
|
|
3280
|
+
};
|
|
3281
|
+
let ready;
|
|
3282
|
+
try {
|
|
3283
|
+
ready = await client.ready();
|
|
3284
|
+
} catch (error) {
|
|
3285
|
+
return {
|
|
3286
|
+
ok: false,
|
|
3287
|
+
message: t("media.storage.unavailable", { plugin: plugin.title }),
|
|
3288
|
+
error
|
|
3289
|
+
};
|
|
3290
|
+
}
|
|
3291
|
+
if (ready.ready) return void 0;
|
|
3292
|
+
return {
|
|
3293
|
+
ok: false,
|
|
3294
|
+
message: t("media.storage.notReady", {
|
|
3295
|
+
plugin: plugin.title,
|
|
3296
|
+
message: ready.message
|
|
3297
|
+
}),
|
|
3298
|
+
...ready.screen && { action: {
|
|
3299
|
+
href: `#/settings/plugin/${encodeURIComponent(plugin.id)}/${encodeURIComponent(ready.screen)}`,
|
|
3300
|
+
label: t("media.storage.settings", { plugin: plugin.title })
|
|
3301
|
+
} }
|
|
3302
|
+
};
|
|
3303
|
+
}
|
|
3304
|
+
/** A file's size for content's list of stored media, with a picture's size where it's known. */
|
|
3305
|
+
function storedFile(size, picture) {
|
|
3306
|
+
return {
|
|
3307
|
+
size,
|
|
3308
|
+
...picture.width && picture.height && {
|
|
3309
|
+
width: picture.width,
|
|
3310
|
+
height: picture.height
|
|
3311
|
+
}
|
|
3312
|
+
};
|
|
3313
|
+
}
|
|
2271
3314
|
/**
|
|
2272
3315
|
* Uploads files to the media folder in one publish, and shows them in the
|
|
2273
3316
|
* admin panel straight away. `replace` overwrites one existing file instead.
|
|
3317
|
+
* Where a plugin keeps the site's media, it keeps the files, larger ones
|
|
3318
|
+
* too, and the publish lists them in `content/media-storage.json`.
|
|
2274
3319
|
*/
|
|
2275
3320
|
function useUploadMedia() {
|
|
2276
3321
|
const t = useStrings();
|
|
2277
|
-
const { publish, mediaPreviews } = useAdmin();
|
|
3322
|
+
const { publish, mediaPreviews, tasks } = useAdmin();
|
|
2278
3323
|
const { content, media } = useSiteContent();
|
|
3324
|
+
const { storage, client } = useMediaStorage();
|
|
3325
|
+
const published = async (changes, message, urls) => {
|
|
3326
|
+
const result = await publish(changes, message, { checks: false });
|
|
3327
|
+
if (!result.ok) return {
|
|
3328
|
+
ok: false,
|
|
3329
|
+
message: t(result.reason === "conflict" ? "publish.conflict" : "publish.error"),
|
|
3330
|
+
failure: result
|
|
3331
|
+
};
|
|
3332
|
+
return {
|
|
3333
|
+
ok: true,
|
|
3334
|
+
urls
|
|
3335
|
+
};
|
|
3336
|
+
};
|
|
3337
|
+
const toStorage = async (files, replace) => {
|
|
3338
|
+
if (!storage || !client) return {
|
|
3339
|
+
ok: false,
|
|
3340
|
+
message: t("publish.error")
|
|
3341
|
+
};
|
|
3342
|
+
const problem = await storageReady(t, client, tasks);
|
|
3343
|
+
if (problem) return problem;
|
|
3344
|
+
const { plugin } = client;
|
|
3345
|
+
const maxBytes = plugin.mediaStorage?.maxBytes ?? MAX_UPLOAD_BYTES;
|
|
3346
|
+
const taken = allMedia(content, media).map(fileName);
|
|
3347
|
+
const added = {};
|
|
3348
|
+
const batch = [];
|
|
3349
|
+
for (const file of files) {
|
|
3350
|
+
let ready;
|
|
3351
|
+
try {
|
|
3352
|
+
ready = await prepareStoredUpload(file, maxBytes);
|
|
3353
|
+
} catch (error) {
|
|
3354
|
+
if (error instanceof UploadError && error.problem === "size") return {
|
|
3355
|
+
ok: false,
|
|
3356
|
+
message: t("media.error.storageSize", {
|
|
3357
|
+
name: file.name,
|
|
3358
|
+
size: formatSize(maxBytes),
|
|
3359
|
+
plugin: plugin.title
|
|
3360
|
+
})
|
|
3361
|
+
};
|
|
3362
|
+
return {
|
|
3363
|
+
ok: false,
|
|
3364
|
+
message: error instanceof UploadError ? t(uploadProblems[error.problem], { name: error.file }) : String(error)
|
|
3365
|
+
};
|
|
3366
|
+
}
|
|
3367
|
+
const url = replace ? mediaUrl(replace) : `${MEDIA_URL_PREFIX}${uploadName(file.name, taken)}`;
|
|
3368
|
+
taken.push(fileName(url));
|
|
3369
|
+
batch.push({
|
|
3370
|
+
address: url,
|
|
3371
|
+
type: ready.type,
|
|
3372
|
+
size: ready.blob.size,
|
|
3373
|
+
data: ready.blob
|
|
3374
|
+
});
|
|
3375
|
+
added[url] = storedFile(ready.blob.size, ready);
|
|
3376
|
+
}
|
|
3377
|
+
try {
|
|
3378
|
+
await client.upload(batch);
|
|
3379
|
+
} catch (error) {
|
|
3380
|
+
const name = batch.map((file) => fileName(file.address)).join(", ");
|
|
3381
|
+
return {
|
|
3382
|
+
ok: false,
|
|
3383
|
+
message: t("media.storage.failed", {
|
|
3384
|
+
name,
|
|
3385
|
+
plugin: plugin.title
|
|
3386
|
+
}),
|
|
3387
|
+
error
|
|
3388
|
+
};
|
|
3389
|
+
}
|
|
3390
|
+
const urls = Object.keys(added);
|
|
3391
|
+
const changes = [mediaStorageFileChange(withStoredFiles(storage, added))];
|
|
3392
|
+
if (replace && media.includes(replace)) changes.push({
|
|
3393
|
+
path: replace,
|
|
3394
|
+
delete: true
|
|
3395
|
+
});
|
|
3396
|
+
const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
|
|
3397
|
+
if (credits) changes.push(credits);
|
|
3398
|
+
const names = urls.map(fileName).join(", ");
|
|
3399
|
+
return published(changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), urls);
|
|
3400
|
+
};
|
|
2279
3401
|
return async (files, replace) => {
|
|
2280
|
-
|
|
3402
|
+
if (storage && client && (!replace || isStorableAddress(mediaUrl(replace)))) return toStorage(files, replace);
|
|
3403
|
+
const taken = allMedia(content, media).map(fileName);
|
|
2281
3404
|
const changes = [];
|
|
2282
3405
|
const uploads = [];
|
|
2283
3406
|
for (const file of files) {
|
|
@@ -2304,16 +3427,12 @@ function useUploadMedia() {
|
|
|
2304
3427
|
for (const upload of uploads) mediaPreviews.remember(upload.url, upload.bytes);
|
|
2305
3428
|
const names = changes.map((change) => fileName(change.path)).join(", ");
|
|
2306
3429
|
const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
|
|
2307
|
-
const
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
};
|
|
2313
|
-
return {
|
|
2314
|
-
ok: true,
|
|
2315
|
-
urls: uploads.map((upload) => upload.url)
|
|
2316
|
-
};
|
|
3430
|
+
const unstored = replace && storage && isStoredMedia(content, mediaUrl(replace)) ? mediaStorageFileChange(withStoredFiles(storage, {}, [mediaUrl(replace)])) : void 0;
|
|
3431
|
+
return published([
|
|
3432
|
+
...changes,
|
|
3433
|
+
...credits ? [credits] : [],
|
|
3434
|
+
...unstored ? [unstored] : []
|
|
3435
|
+
], replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), uploads.map((upload) => upload.url));
|
|
2317
3436
|
};
|
|
2318
3437
|
}
|
|
2319
3438
|
function DeleteMediaDialog({ url, onClose }) {
|
|
@@ -2329,18 +3448,21 @@ function DeleteMediaDialog({ url, onClose }) {
|
|
|
2329
3448
|
settings: t("nav.settings"),
|
|
2330
3449
|
css: t("settings.tab.css")
|
|
2331
3450
|
});
|
|
3451
|
+
const { storage, client } = useMediaStorage();
|
|
2332
3452
|
const onDelete = async () => {
|
|
2333
3453
|
const path = mediaPath(url);
|
|
2334
3454
|
if (!path) return;
|
|
2335
3455
|
setBusy(true);
|
|
2336
3456
|
const credits = withoutCredits(content.credits, [url]);
|
|
2337
|
-
const
|
|
3457
|
+
const stored = storage && isStoredMedia(content, url);
|
|
3458
|
+
const result = await publish([stored ? mediaStorageFileChange(withStoredFiles(storage, {}, [url])) : {
|
|
2338
3459
|
path,
|
|
2339
3460
|
delete: true
|
|
2340
3461
|
}, ...credits ? [credits] : []], t("media.deleteMessage", { name }));
|
|
2341
3462
|
setBusy(false);
|
|
2342
|
-
if (result.ok)
|
|
2343
|
-
|
|
3463
|
+
if (!result.ok) return setFailure(result);
|
|
3464
|
+
if (stored) client?.remove([url]).catch(() => {});
|
|
3465
|
+
onClose();
|
|
2344
3466
|
};
|
|
2345
3467
|
return /* @__PURE__ */ jsxs(Dialog, {
|
|
2346
3468
|
title: t("media.deleteTitle", { name }),
|
|
@@ -2408,26 +3530,41 @@ function UploadStatus({ status }) {
|
|
|
2408
3530
|
const t = useStrings();
|
|
2409
3531
|
if (status.type === "uploading") return /* @__PURE__ */ jsx("p", {
|
|
2410
3532
|
className: "gfa-notice",
|
|
2411
|
-
|
|
3533
|
+
role: "status",
|
|
3534
|
+
children: status.plugin ? t("media.storage.uploading", { plugin: status.plugin }) : t("media.uploading")
|
|
2412
3535
|
});
|
|
2413
3536
|
if (status.type === "done") return /* @__PURE__ */ jsx("p", {
|
|
2414
3537
|
className: "gfa-notice gfa-notice-success",
|
|
2415
3538
|
role: "status",
|
|
2416
3539
|
children: t("media.uploaded")
|
|
2417
3540
|
});
|
|
2418
|
-
if (status.type === "failed")
|
|
3541
|
+
if (status.type === "failed") {
|
|
3542
|
+
if (status.failure) return /* @__PURE__ */ jsx(PublishFailure, { failure: status.failure });
|
|
3543
|
+
return /* @__PURE__ */ jsx(ErrorMessage, {
|
|
3544
|
+
message: status.message,
|
|
3545
|
+
error: status.error,
|
|
3546
|
+
action: status.action && /* @__PURE__ */ jsx(AppLink, {
|
|
3547
|
+
href: status.action.href,
|
|
3548
|
+
children: status.action.label
|
|
3549
|
+
})
|
|
3550
|
+
});
|
|
3551
|
+
}
|
|
2419
3552
|
return null;
|
|
2420
3553
|
}
|
|
2421
3554
|
function useUploads() {
|
|
2422
3555
|
const upload = useUploadMedia();
|
|
3556
|
+
const { storage, client } = useMediaStorage();
|
|
2423
3557
|
const [status, setStatus] = useState({ type: "idle" });
|
|
2424
3558
|
const run = async (files, replace) => {
|
|
2425
|
-
|
|
3559
|
+
const waits = storage && client?.plugin.tasks?.length && (!replace || isStorableAddress(mediaUrl(replace)));
|
|
3560
|
+
setStatus({
|
|
3561
|
+
type: "uploading",
|
|
3562
|
+
...waits && { plugin: client.plugin.title }
|
|
3563
|
+
});
|
|
2426
3564
|
const result = await upload(files, replace);
|
|
2427
3565
|
setStatus(result.ok ? { type: "done" } : {
|
|
2428
3566
|
type: "failed",
|
|
2429
|
-
|
|
2430
|
-
failure: result.failure
|
|
3567
|
+
...result
|
|
2431
3568
|
});
|
|
2432
3569
|
return result;
|
|
2433
3570
|
};
|
|
@@ -2436,17 +3573,280 @@ function useUploads() {
|
|
|
2436
3573
|
run
|
|
2437
3574
|
};
|
|
2438
3575
|
}
|
|
3576
|
+
/** Moves the site's own media files to the plugin that keeps media, in one publish once each is kept. */
|
|
3577
|
+
function MoveToStorageDialog({ client, paths, onClose }) {
|
|
3578
|
+
const t = useStrings();
|
|
3579
|
+
const { publish, readBytes, tasks } = useAdmin();
|
|
3580
|
+
const { storage } = useMediaStorage();
|
|
3581
|
+
const [progress, setProgress] = useState();
|
|
3582
|
+
const [problem, setProblem] = useState();
|
|
3583
|
+
const [failure, setFailure] = useState(null);
|
|
3584
|
+
const plugin = client.plugin.title;
|
|
3585
|
+
const move = async () => {
|
|
3586
|
+
if (!storage) return;
|
|
3587
|
+
setProblem(void 0);
|
|
3588
|
+
setFailure(null);
|
|
3589
|
+
const unready = await storageReady(t, client, tasks);
|
|
3590
|
+
if (unready) return setProblem(unready);
|
|
3591
|
+
setProgress({ done: 0 });
|
|
3592
|
+
const added = {};
|
|
3593
|
+
let failed;
|
|
3594
|
+
for (let start = 0; start < paths.length && !failed; start += MOVE_BATCH) {
|
|
3595
|
+
const batch = [];
|
|
3596
|
+
const sizes = {};
|
|
3597
|
+
try {
|
|
3598
|
+
for (const path of paths.slice(start, start + MOVE_BATCH)) {
|
|
3599
|
+
const url = mediaUrl(path);
|
|
3600
|
+
const bytes = await readBytes(path);
|
|
3601
|
+
if (!bytes) continue;
|
|
3602
|
+
const blob = new Blob([new Uint8Array(bytes)], { type: mediaType(path) });
|
|
3603
|
+
batch.push({
|
|
3604
|
+
address: url,
|
|
3605
|
+
type: blob.type,
|
|
3606
|
+
size: blob.size,
|
|
3607
|
+
data: blob
|
|
3608
|
+
});
|
|
3609
|
+
sizes[url] = storedFile(blob.size, await imageSize(blob, path) ?? {});
|
|
3610
|
+
}
|
|
3611
|
+
if (batch.length > 0) await client.upload(batch);
|
|
3612
|
+
Object.assign(added, sizes);
|
|
3613
|
+
} catch (error) {
|
|
3614
|
+
const name = batch.map((file) => fileName(file.address)).join(", ");
|
|
3615
|
+
failed = {
|
|
3616
|
+
ok: false,
|
|
3617
|
+
message: t("media.storage.failed", {
|
|
3618
|
+
name,
|
|
3619
|
+
plugin
|
|
3620
|
+
}),
|
|
3621
|
+
error
|
|
3622
|
+
};
|
|
3623
|
+
}
|
|
3624
|
+
setProgress({ done: Math.min(start + MOVE_BATCH, paths.length) });
|
|
3625
|
+
}
|
|
3626
|
+
const moved = Object.keys(added);
|
|
3627
|
+
if (moved.length === 0) {
|
|
3628
|
+
setProgress(void 0);
|
|
3629
|
+
return setProblem(failed ?? {
|
|
3630
|
+
ok: false,
|
|
3631
|
+
message: t("media.move.nothing")
|
|
3632
|
+
});
|
|
3633
|
+
}
|
|
3634
|
+
const result = await publish([mediaStorageFileChange(withStoredFiles(storage, added)), ...moved.flatMap((url) => {
|
|
3635
|
+
const path = mediaPath(url);
|
|
3636
|
+
return path ? [{
|
|
3637
|
+
path,
|
|
3638
|
+
delete: true
|
|
3639
|
+
}] : [];
|
|
3640
|
+
})], t("media.move.message", { plugin }), { checks: false });
|
|
3641
|
+
setProgress(void 0);
|
|
3642
|
+
if (!result.ok) return setFailure(result);
|
|
3643
|
+
if (failed) return setProblem({
|
|
3644
|
+
...failed,
|
|
3645
|
+
message: `${t("media.move.some", {
|
|
3646
|
+
count: String(moved.length),
|
|
3647
|
+
plugin
|
|
3648
|
+
})} ${failed.message}`
|
|
3649
|
+
});
|
|
3650
|
+
onClose(t("media.move.done", {
|
|
3651
|
+
count: String(moved.length),
|
|
3652
|
+
plugin
|
|
3653
|
+
}));
|
|
3654
|
+
};
|
|
3655
|
+
return /* @__PURE__ */ jsxs(Dialog, {
|
|
3656
|
+
title: t("media.move.title", { plugin }),
|
|
3657
|
+
onClose: () => onClose(),
|
|
3658
|
+
children: [
|
|
3659
|
+
/* @__PURE__ */ jsx("p", { children: t("media.move.body", { plugin }) }),
|
|
3660
|
+
progress && /* @__PURE__ */ jsxs("p", {
|
|
3661
|
+
className: "gfa-notice",
|
|
3662
|
+
role: "status",
|
|
3663
|
+
children: [t("media.move.moving", {
|
|
3664
|
+
done: String(progress.done),
|
|
3665
|
+
total: String(paths.length)
|
|
3666
|
+
}), client.plugin.tasks?.length ? ` ${t("media.storage.wait")}` : ""]
|
|
3667
|
+
}),
|
|
3668
|
+
problem && /* @__PURE__ */ jsx(ErrorMessage, {
|
|
3669
|
+
message: problem.message,
|
|
3670
|
+
error: problem.error,
|
|
3671
|
+
action: problem.action && /* @__PURE__ */ jsx(AppLink, {
|
|
3672
|
+
href: problem.action.href,
|
|
3673
|
+
children: problem.action.label
|
|
3674
|
+
})
|
|
3675
|
+
}),
|
|
3676
|
+
/* @__PURE__ */ jsx(PublishFailure, { failure }),
|
|
3677
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3678
|
+
className: "gfa-dialog-actions",
|
|
3679
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
3680
|
+
onClick: () => onClose(),
|
|
3681
|
+
children: t("action.cancel")
|
|
3682
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
3683
|
+
variant: "primary",
|
|
3684
|
+
disabled: progress !== void 0,
|
|
3685
|
+
onClick: () => void move(),
|
|
3686
|
+
children: progress ? t("media.move.busy") : t("media.move.button", {
|
|
3687
|
+
count: String(paths.length),
|
|
3688
|
+
plugin
|
|
3689
|
+
})
|
|
3690
|
+
})]
|
|
3691
|
+
})
|
|
3692
|
+
]
|
|
3693
|
+
});
|
|
3694
|
+
}
|
|
3695
|
+
/**
|
|
3696
|
+
* Copies the files a plugin keeps back into the site's own files, from their
|
|
3697
|
+
* public addresses, up to the site's own 25 MB each, which needs no plugin.
|
|
3698
|
+
* Once none are left, the site stops keeping media outside its files.
|
|
3699
|
+
*/
|
|
3700
|
+
function MoveBackDialog({ plugin, onClose }) {
|
|
3701
|
+
const t = useStrings();
|
|
3702
|
+
const { publish } = useAdmin();
|
|
3703
|
+
const { content } = useSiteContent();
|
|
3704
|
+
const { storage, client } = useMediaStorage();
|
|
3705
|
+
const [busy, setBusy] = useState(false);
|
|
3706
|
+
const [problem, setProblem] = useState();
|
|
3707
|
+
const [failure, setFailure] = useState(null);
|
|
3708
|
+
const files = Object.entries(storage?.files ?? {});
|
|
3709
|
+
const large = files.filter(([, file]) => file.size > MAX_UPLOAD_BYTES).map(([url]) => fileName(url));
|
|
3710
|
+
const moveBack = async () => {
|
|
3711
|
+
if (!storage) return;
|
|
3712
|
+
setBusy(true);
|
|
3713
|
+
setProblem(void 0);
|
|
3714
|
+
setFailure(null);
|
|
3715
|
+
const changes = [];
|
|
3716
|
+
const moved = [];
|
|
3717
|
+
for (const [url, file] of files) {
|
|
3718
|
+
const path = mediaPath(url);
|
|
3719
|
+
const from = storedMediaUrl(storage, url);
|
|
3720
|
+
if (!path || !from || file.size > MAX_UPLOAD_BYTES) continue;
|
|
3721
|
+
try {
|
|
3722
|
+
const response = await fetch(from, { cache: "no-store" });
|
|
3723
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
3724
|
+
changes.push({
|
|
3725
|
+
path,
|
|
3726
|
+
bytes: new Uint8Array(await response.arrayBuffer())
|
|
3727
|
+
});
|
|
3728
|
+
moved.push(url);
|
|
3729
|
+
} catch (error) {
|
|
3730
|
+
setBusy(false);
|
|
3731
|
+
return setProblem({
|
|
3732
|
+
message: t("media.back.failed", {
|
|
3733
|
+
name: fileName(url),
|
|
3734
|
+
plugin
|
|
3735
|
+
}),
|
|
3736
|
+
error
|
|
3737
|
+
});
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
const left = withStoredFiles(storage, {}, moved);
|
|
3741
|
+
changes.push(mediaStorageFileChange(Object.keys(left.files).length > 0 ? left : void 0));
|
|
3742
|
+
const result = await publish(changes, t("media.back.message", { plugin }), { checks: false });
|
|
3743
|
+
setBusy(false);
|
|
3744
|
+
if (!result.ok) return setFailure(result);
|
|
3745
|
+
if (moved.length > 0) client?.remove(moved).catch(() => {});
|
|
3746
|
+
onClose(t("media.back.done", {
|
|
3747
|
+
count: String(moved.length),
|
|
3748
|
+
plugin
|
|
3749
|
+
}));
|
|
3750
|
+
};
|
|
3751
|
+
return /* @__PURE__ */ jsxs(Dialog, {
|
|
3752
|
+
title: t("media.back.title", { plugin }),
|
|
3753
|
+
onClose: () => onClose(),
|
|
3754
|
+
children: [
|
|
3755
|
+
/* @__PURE__ */ jsx("p", { children: files.length > 0 ? t("media.back.body", { plugin }) : t("media.back.empty", { plugin }) }),
|
|
3756
|
+
large.length > 0 && /* @__PURE__ */ jsx("p", { children: t("media.back.large", {
|
|
3757
|
+
names: large.join(", "),
|
|
3758
|
+
plugin
|
|
3759
|
+
}) }),
|
|
3760
|
+
content.mediaStorage && problem && /* @__PURE__ */ jsx(ErrorMessage, {
|
|
3761
|
+
message: problem.message,
|
|
3762
|
+
error: problem.error
|
|
3763
|
+
}),
|
|
3764
|
+
/* @__PURE__ */ jsx(PublishFailure, { failure }),
|
|
3765
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3766
|
+
className: "gfa-dialog-actions",
|
|
3767
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
3768
|
+
onClick: () => onClose(),
|
|
3769
|
+
children: t("action.cancel")
|
|
3770
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
3771
|
+
variant: "primary",
|
|
3772
|
+
disabled: busy,
|
|
3773
|
+
onClick: () => void moveBack(),
|
|
3774
|
+
children: busy ? t("media.move.busy") : t("media.back.button")
|
|
3775
|
+
})]
|
|
3776
|
+
})
|
|
3777
|
+
]
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
/** Where a plugin keeps the site's media: what it keeps, and moving the site's own files there or back. */
|
|
3781
|
+
function StorageSection({ onDone }) {
|
|
3782
|
+
const t = useStrings();
|
|
3783
|
+
const { content, media } = useSiteContent();
|
|
3784
|
+
const { storage, client } = useMediaStorage();
|
|
3785
|
+
const [moving, setMoving] = useState();
|
|
3786
|
+
if (!storage) return null;
|
|
3787
|
+
const plugin = client?.plugin.title ?? storage.plugin;
|
|
3788
|
+
const count = String(Object.keys(storage.files).length);
|
|
3789
|
+
const movable = media.filter((path) => isStorableAddress(mediaUrl(path)) && !isStoredMedia(content, mediaUrl(path)));
|
|
3790
|
+
const close = (message) => {
|
|
3791
|
+
setMoving(void 0);
|
|
3792
|
+
if (message) onDone(message);
|
|
3793
|
+
};
|
|
3794
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
3795
|
+
className: "gfa-media-storage",
|
|
3796
|
+
"aria-label": t("media.storage.title"),
|
|
3797
|
+
children: [
|
|
3798
|
+
/* @__PURE__ */ jsx("p", {
|
|
3799
|
+
className: "gfa-notice",
|
|
3800
|
+
role: "note",
|
|
3801
|
+
children: client ? t("media.storage.intro", {
|
|
3802
|
+
plugin,
|
|
3803
|
+
count
|
|
3804
|
+
}) : t("media.storage.missing", {
|
|
3805
|
+
plugin,
|
|
3806
|
+
count
|
|
3807
|
+
})
|
|
3808
|
+
}),
|
|
3809
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3810
|
+
className: "gfa-dialog-actions",
|
|
3811
|
+
children: [client && movable.length > 0 && /* @__PURE__ */ jsx(Button, {
|
|
3812
|
+
variant: "primary",
|
|
3813
|
+
onClick: () => setMoving("to"),
|
|
3814
|
+
children: t("media.move.button", {
|
|
3815
|
+
count: String(movable.length),
|
|
3816
|
+
plugin
|
|
3817
|
+
})
|
|
3818
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
3819
|
+
variant: "ghost",
|
|
3820
|
+
onClick: () => setMoving("back"),
|
|
3821
|
+
children: t("media.back.button")
|
|
3822
|
+
})]
|
|
3823
|
+
}),
|
|
3824
|
+
moving === "to" && client && /* @__PURE__ */ jsx(MoveToStorageDialog, {
|
|
3825
|
+
client,
|
|
3826
|
+
paths: movable,
|
|
3827
|
+
onClose: close
|
|
3828
|
+
}),
|
|
3829
|
+
moving === "back" && /* @__PURE__ */ jsx(MoveBackDialog, {
|
|
3830
|
+
plugin,
|
|
3831
|
+
onClose: close
|
|
3832
|
+
})
|
|
3833
|
+
]
|
|
3834
|
+
});
|
|
3835
|
+
}
|
|
2439
3836
|
/** Every uploaded image and file, with buttons to upload, replace and delete them. */
|
|
2440
3837
|
/** Cloudflare's terms for its CDN, which limit serving video on its free plans. */
|
|
2441
3838
|
const CLOUDFLARE_TERMS = "https://www.cloudflare.com/service-specific-terms-application-services/";
|
|
2442
3839
|
function MediaScreen() {
|
|
2443
3840
|
const t = useStrings();
|
|
2444
|
-
const { content, media } = useSiteContent();
|
|
3841
|
+
const { content, media: own } = useSiteContent();
|
|
3842
|
+
const media = useMemo(() => allMedia(content, own), [content, own]);
|
|
2445
3843
|
const { status, run } = useUploads();
|
|
2446
3844
|
const [query, setQuery] = useState("");
|
|
2447
3845
|
const [deleting, setDeleting] = useState(null);
|
|
2448
3846
|
const [copied, setCopied] = useState(null);
|
|
3847
|
+
const [moved, setMoved] = useState();
|
|
2449
3848
|
const searchId = useId();
|
|
3849
|
+
const mayChange = useMayChange({ kind: "media" });
|
|
2450
3850
|
useEffect(() => {
|
|
2451
3851
|
const stop = (event) => {
|
|
2452
3852
|
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
|
|
@@ -2465,7 +3865,7 @@ function MediaScreen() {
|
|
|
2465
3865
|
children: [
|
|
2466
3866
|
/* @__PURE__ */ jsxs("div", {
|
|
2467
3867
|
className: "gfa-screen-header",
|
|
2468
|
-
children: [/* @__PURE__ */ jsx("h1", { children: t("media.title") }), /* @__PURE__ */ jsx(UploadButton, {
|
|
3868
|
+
children: [/* @__PURE__ */ jsx("h1", { children: t("media.title") }), mayChange && /* @__PURE__ */ jsx(UploadButton, {
|
|
2469
3869
|
label: t("media.upload"),
|
|
2470
3870
|
disabled: status.type === "uploading",
|
|
2471
3871
|
onFiles: (files) => void run(files)
|
|
@@ -2475,6 +3875,11 @@ function MediaScreen() {
|
|
|
2475
3875
|
className: "gfa-hint",
|
|
2476
3876
|
children: t("media.intro")
|
|
2477
3877
|
}),
|
|
3878
|
+
!mayChange && /* @__PURE__ */ jsx("p", {
|
|
3879
|
+
className: "gfa-notice",
|
|
3880
|
+
role: "note",
|
|
3881
|
+
children: t("limits.mediaReadOnly")
|
|
3882
|
+
}),
|
|
2478
3883
|
cloudflareVideos && /* @__PURE__ */ jsxs("p", {
|
|
2479
3884
|
className: "gfa-notice",
|
|
2480
3885
|
role: "note",
|
|
@@ -2489,6 +3894,12 @@ function MediaScreen() {
|
|
|
2489
3894
|
})
|
|
2490
3895
|
]
|
|
2491
3896
|
}),
|
|
3897
|
+
mayChange && /* @__PURE__ */ jsx(StorageSection, { onDone: setMoved }),
|
|
3898
|
+
moved && /* @__PURE__ */ jsx("p", {
|
|
3899
|
+
className: "gfa-notice gfa-notice-success",
|
|
3900
|
+
role: "status",
|
|
3901
|
+
children: moved
|
|
3902
|
+
}),
|
|
2492
3903
|
/* @__PURE__ */ jsx(UploadStatus, { status }),
|
|
2493
3904
|
media.length > 0 && /* @__PURE__ */ jsxs("div", {
|
|
2494
3905
|
className: "gfa-field gfa-media-search",
|
|
@@ -2505,7 +3916,7 @@ function MediaScreen() {
|
|
|
2505
3916
|
})]
|
|
2506
3917
|
}),
|
|
2507
3918
|
/* @__PURE__ */ jsx(DropZone, {
|
|
2508
|
-
onFiles: (files) => void run(files),
|
|
3919
|
+
onFiles: (files) => void (mayChange && run(files)),
|
|
2509
3920
|
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
3921
|
className: "gfa-media-grid",
|
|
2511
3922
|
children: urls.map((url) => /* @__PURE__ */ jsxs("li", {
|
|
@@ -2518,27 +3929,27 @@ function MediaScreen() {
|
|
|
2518
3929
|
children: fileName(url)
|
|
2519
3930
|
}),
|
|
2520
3931
|
content.credits?.[url] && /* @__PURE__ */ jsx(MediaCredit, { credit: content.credits[url] }),
|
|
3932
|
+
isStoredMedia(content, url) && /* @__PURE__ */ jsx("span", {
|
|
3933
|
+
className: "gfa-badge",
|
|
3934
|
+
children: t("media.stored", { plugin: content.mediaStorage?.plugin ?? "" })
|
|
3935
|
+
}),
|
|
2521
3936
|
/* @__PURE__ */ jsxs("div", {
|
|
2522
3937
|
className: "gfa-media-actions",
|
|
2523
|
-
children: [
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
onClick: () => setDeleting(url),
|
|
2539
|
-
children: t("media.delete")
|
|
2540
|
-
})
|
|
2541
|
-
]
|
|
3938
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
3939
|
+
variant: "ghost",
|
|
3940
|
+
onClick: () => void navigator.clipboard?.writeText(url).then(() => setCopied(url), () => setCopied(null)),
|
|
3941
|
+
children: copied === url ? t("media.copied") : t("media.copy")
|
|
3942
|
+
}), mayChange && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(UploadButton, {
|
|
3943
|
+
label: t("media.replace"),
|
|
3944
|
+
variant: "secondary",
|
|
3945
|
+
multiple: false,
|
|
3946
|
+
disabled: status.type === "uploading",
|
|
3947
|
+
onFiles: (files) => void run(files.slice(0, 1), mediaPath(url))
|
|
3948
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
3949
|
+
variant: "ghost",
|
|
3950
|
+
onClick: () => setDeleting(url),
|
|
3951
|
+
children: t("media.delete")
|
|
3952
|
+
})] })]
|
|
2542
3953
|
})
|
|
2543
3954
|
]
|
|
2544
3955
|
}, url))
|
|
@@ -2554,22 +3965,26 @@ function MediaScreen() {
|
|
|
2554
3965
|
const PICKER_TITLES = {
|
|
2555
3966
|
image: "media.pickerTitle",
|
|
2556
3967
|
video: "media.pickerTitleVideo",
|
|
3968
|
+
audio: "media.pickerTitleAudio",
|
|
2557
3969
|
file: "media.pickerTitleFile"
|
|
2558
3970
|
};
|
|
2559
3971
|
const CHOOSE_LABELS = {
|
|
2560
3972
|
image: "media.choose",
|
|
2561
3973
|
video: "media.chooseVideo",
|
|
3974
|
+
audio: "media.chooseAudio",
|
|
2562
3975
|
file: "media.chooseFile"
|
|
2563
3976
|
};
|
|
2564
3977
|
const ACCEPT = {
|
|
2565
3978
|
image: "image/*",
|
|
2566
3979
|
video: "video/mp4,video/webm",
|
|
3980
|
+
audio: "audio/mpeg,audio/mp4,.mp3,.m4a",
|
|
2567
3981
|
file: void 0
|
|
2568
3982
|
};
|
|
2569
3983
|
/** Picks a file from the media library, or uploads a new one, for a field. */
|
|
2570
3984
|
function MediaPickerDialog({ kind, onSelect, onClose }) {
|
|
2571
3985
|
const t = useStrings();
|
|
2572
|
-
const { media } = useSiteContent();
|
|
3986
|
+
const { content, media: own } = useSiteContent();
|
|
3987
|
+
const media = allMedia(content, own);
|
|
2573
3988
|
const { status, run } = useUploads();
|
|
2574
3989
|
const urls = media.map(mediaUrl).filter((url) => kind === "file" || mediaKind$1(url) === kind).sort((a, b) => fileName(a).localeCompare(fileName(b)));
|
|
2575
3990
|
return /* @__PURE__ */ jsx(Dialog, {
|
|
@@ -2684,214 +4099,91 @@ function MediaField({ label, hint, error, value, kind = "image", onChange }) {
|
|
|
2684
4099
|
});
|
|
2685
4100
|
}
|
|
2686
4101
|
//#endregion
|
|
2687
|
-
//#region src/
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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";
|
|
4102
|
+
//#region src/plugin-secrets.ts
|
|
4103
|
+
/** The lines a plugin's secrets take in `.env.local`, each waiting for its value after the `=`. */
|
|
4104
|
+
function envLines(names) {
|
|
4105
|
+
return names.map((name) => `${name}=`).join("\n");
|
|
2707
4106
|
}
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
}
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
4107
|
+
/** How to add a plugin's secrets where a site keeps them, as its screens show it, in the admin panel's words. */
|
|
4108
|
+
function helpFor(place, names, t) {
|
|
4109
|
+
const list = names.join(", ");
|
|
4110
|
+
switch (place.kind) {
|
|
4111
|
+
case "github": return {
|
|
4112
|
+
where: "github",
|
|
4113
|
+
title: t("plugins.secrets.github.title"),
|
|
4114
|
+
link: {
|
|
4115
|
+
url: place.url,
|
|
4116
|
+
label: t("plugins.secrets.github.link")
|
|
4117
|
+
},
|
|
4118
|
+
steps: [{ text: t("plugins.secrets.github.open") }, ...names.map((name) => ({
|
|
4119
|
+
text: t("plugins.secrets.github.add", { name }),
|
|
4120
|
+
code: name
|
|
4121
|
+
}))],
|
|
4122
|
+
notes: [t("plugins.secrets.github.who"), t("plugins.secrets.github.kind")]
|
|
4123
|
+
};
|
|
4124
|
+
case "gitlab": return {
|
|
4125
|
+
where: "gitlab",
|
|
4126
|
+
title: t("plugins.secrets.gitlab.title"),
|
|
4127
|
+
link: {
|
|
4128
|
+
url: place.url,
|
|
4129
|
+
label: t("plugins.secrets.gitlab.link")
|
|
4130
|
+
},
|
|
4131
|
+
steps: [{ text: t("plugins.secrets.gitlab.open") }, ...names.map((name) => ({
|
|
4132
|
+
text: t("plugins.secrets.gitlab.add", { name }),
|
|
4133
|
+
code: name
|
|
4134
|
+
}))],
|
|
4135
|
+
notes: [t("plugins.secrets.gitlab.who")]
|
|
4136
|
+
};
|
|
4137
|
+
case "bitbucket": return {
|
|
4138
|
+
where: "bitbucket",
|
|
4139
|
+
title: t("plugins.secrets.bitbucket.title"),
|
|
4140
|
+
link: {
|
|
4141
|
+
url: place.url,
|
|
4142
|
+
label: t("plugins.secrets.bitbucket.link")
|
|
4143
|
+
},
|
|
4144
|
+
steps: [{ text: t("plugins.secrets.bitbucket.open") }, ...names.map((name) => ({
|
|
4145
|
+
text: t("plugins.secrets.bitbucket.add", { name }),
|
|
4146
|
+
code: name
|
|
4147
|
+
}))],
|
|
4148
|
+
notes: [t("plugins.secrets.bitbucket.who")]
|
|
4149
|
+
};
|
|
4150
|
+
case "goodfellow-dev":
|
|
4151
|
+
case "next-dev": return {
|
|
4152
|
+
where: "development",
|
|
4153
|
+
title: t("plugins.secrets.development.title"),
|
|
4154
|
+
steps: [{
|
|
4155
|
+
text: t("plugins.secrets.development.file", { names: list }),
|
|
4156
|
+
code: envLines(names)
|
|
4157
|
+
}, { text: t("plugins.secrets.development.values") }],
|
|
4158
|
+
notes: [t("plugins.secrets.development.local")]
|
|
4159
|
+
};
|
|
4160
|
+
case "server": return {
|
|
4161
|
+
where: "server",
|
|
4162
|
+
title: t("plugins.secrets.server.title"),
|
|
4163
|
+
steps: [{ text: t("plugins.secrets.server.add", { names: list }) }, { text: t("plugins.secrets.server.deploy") }],
|
|
4164
|
+
notes: [t("plugins.secrets.server.who")]
|
|
4165
|
+
};
|
|
4166
|
+
}
|
|
2755
4167
|
}
|
|
2756
|
-
const URL_ATTRIBUTES = [
|
|
2757
|
-
"src",
|
|
2758
|
-
"href",
|
|
2759
|
-
"poster"
|
|
2760
|
-
];
|
|
2761
|
-
const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
|
|
2762
4168
|
/**
|
|
2763
|
-
*
|
|
2764
|
-
*
|
|
2765
|
-
*
|
|
4169
|
+
* How the site's owner adds the secrets a plugin declared, for this site as
|
|
4170
|
+
* it's set up: where its tasks get them, and, on a full-stack site whose
|
|
4171
|
+
* plugin has a server module, the server first, with the CI as well.
|
|
2766
4172
|
*/
|
|
2767
|
-
function
|
|
2768
|
-
const
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
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
|
-
};
|
|
4173
|
+
function pluginSecretsHelp(place, plugin, t) {
|
|
4174
|
+
const names = plugin.permissions.secrets ?? [];
|
|
4175
|
+
if (!place || names.length === 0) return [];
|
|
4176
|
+
if (place.kind === "server") {
|
|
4177
|
+
const ci = place.ci && helpFor(place.ci, names, t);
|
|
4178
|
+
return [plugin.server ? helpFor(place, names, t) : void 0, ci].filter((help) => help !== void 0);
|
|
4179
|
+
}
|
|
4180
|
+
const help = helpFor(place, names, t);
|
|
4181
|
+
return help ? [help] : [];
|
|
2819
4182
|
}
|
|
2820
4183
|
//#endregion
|
|
2821
4184
|
//#region src/registry-version.ts
|
|
2822
4185
|
/** 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.
|
|
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
|
-
}
|
|
4186
|
+
const REGISTRY_VERSION = "0.7.0";
|
|
2895
4187
|
//#endregion
|
|
2896
4188
|
//#region src/schedule.tsx
|
|
2897
4189
|
/** The computer's own time zone, which a site without one takes when something is first scheduled. */
|
|
@@ -3242,11 +4534,15 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3242
4534
|
const [draftPaths, setDraftPaths] = useState([]);
|
|
3243
4535
|
const content = draft?.store ?? store;
|
|
3244
4536
|
const [checking, setChecking] = useState();
|
|
4537
|
+
const [limiting, setLimiting] = useState();
|
|
4538
|
+
const [draftReviews, setDraftReviews] = useState([]);
|
|
3245
4539
|
const blocks = useMemo(() => describedConfig(site.description, "template").components, [site.description]);
|
|
3246
|
-
const
|
|
4540
|
+
const storage = useRef(void 0);
|
|
4541
|
+
storage.current = state.status === "ready" ? state.content.mediaStorage : void 0;
|
|
4542
|
+
const mediaPreviews = useMemo(() => createMediaPreviews(content, siteUrl, () => storage.current), [content, siteUrl]);
|
|
3247
4543
|
const outOfReach = registry !== void 0 || atAdminAddress(site.admin) || site.broker !== void 0;
|
|
3248
4544
|
const registries = useMemo(() => ({
|
|
3249
|
-
[GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.
|
|
4545
|
+
[GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.7.0"),
|
|
3250
4546
|
...outOfReach && {
|
|
3251
4547
|
[SHADCN_NAMESPACE]: SHADCN_REGISTRY,
|
|
3252
4548
|
...site.registries
|
|
@@ -3273,6 +4569,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3273
4569
|
useEffect(() => favicon ? showSiteIcon(document, favicon, mediaPreviews) : void 0, [favicon, mediaPreviews]);
|
|
3274
4570
|
const readFile = useCallback((path) => content.read(path), [content]);
|
|
3275
4571
|
const listFiles = useCallback((dir) => content.list(dir), [content]);
|
|
4572
|
+
const readBytes = useCallback((path) => content.readBytes(path), [content]);
|
|
3276
4573
|
const demo = isDemoStore(store) ? store : void 0;
|
|
3277
4574
|
const pages = isGitBackend(store) ? store.pages : void 0;
|
|
3278
4575
|
const versions = isGitBackend(store) ? store : void 0;
|
|
@@ -3281,6 +4578,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3281
4578
|
const updates = isGitBackend(store) ? store.updates : void 0;
|
|
3282
4579
|
const contentOnly = isGitBackend(store) && store.contentOnly === true;
|
|
3283
4580
|
const scheduledBuilds = isGitBackend(store) ? store.scheduledBuilds : void 0;
|
|
4581
|
+
const tasks = "tasks" in store ? store.tasks : void 0;
|
|
3284
4582
|
const repositoryVisibility = useMemo(() => isGitBackend(store) ? store.repositoryVisibility?.bind(store) : void 0, [store]);
|
|
3285
4583
|
const [scheduleSyncProblem, setScheduleSyncProblem] = useState();
|
|
3286
4584
|
const backendOwner = isGitBackend(store) ? store.ownerAccess : void 0;
|
|
@@ -3342,9 +4640,14 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3342
4640
|
reload
|
|
3343
4641
|
]);
|
|
3344
4642
|
const refreshDrafts = useCallback(async (current) => {
|
|
3345
|
-
const [names, paths] = await Promise.all([
|
|
4643
|
+
const [names, paths, reviews] = await Promise.all([
|
|
4644
|
+
siteDrafts.list().catch(() => []),
|
|
4645
|
+
current ? current.paths().catch(() => []) : [],
|
|
4646
|
+
siteDrafts.reviews().catch(() => [])
|
|
4647
|
+
]);
|
|
3346
4648
|
setDraftNames(names);
|
|
3347
4649
|
setDraftPaths(paths);
|
|
4650
|
+
setDraftReviews(reviews);
|
|
3348
4651
|
}, [siteDrafts]);
|
|
3349
4652
|
useEffect(() => {
|
|
3350
4653
|
if (draftFound) refreshDrafts(draft);
|
|
@@ -3364,12 +4667,64 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3364
4667
|
setChecking(void 0);
|
|
3365
4668
|
return go;
|
|
3366
4669
|
}, []);
|
|
4670
|
+
const limits = useMemo(() => state.status === "ready" ? limitsFor(state.content.permissions, account?.user.login) : void 0, [state, account]);
|
|
4671
|
+
/** Who's asked to review: those the permissions name, else every owner, never the one asking. */
|
|
4672
|
+
const reviewers = useCallback(async () => {
|
|
4673
|
+
const owners = await editors?.list().then((list) => list.editors.flatMap((editor) => editor.role === "owner" && editor.login ? [editor.login] : [])).catch(() => []);
|
|
4674
|
+
const self = account?.user.login.toLowerCase();
|
|
4675
|
+
const permissions = state.status === "ready" ? state.content.permissions : void 0;
|
|
4676
|
+
return reviewersFor(permissions, owners ?? []).filter((login) => login.toLowerCase() !== self);
|
|
4677
|
+
}, [
|
|
4678
|
+
editors,
|
|
4679
|
+
account,
|
|
4680
|
+
state
|
|
4681
|
+
]);
|
|
4682
|
+
/** Shows what's outside someone's limits, and lets them submit it for review where the site can take one. */
|
|
4683
|
+
const askLimits = useCallback((problems, shown, submit) => new Promise((done) => setLimiting({
|
|
4684
|
+
problems,
|
|
4685
|
+
content: shown,
|
|
4686
|
+
...submit && { submit },
|
|
4687
|
+
done
|
|
4688
|
+
})), []);
|
|
3367
4689
|
const publish = useCallback(async (changes, message, options) => {
|
|
3368
4690
|
if (state.status !== "ready") return {
|
|
3369
4691
|
ok: false,
|
|
3370
4692
|
reason: "error",
|
|
3371
4693
|
error: /* @__PURE__ */ new Error("The site isn't loaded.")
|
|
3372
4694
|
};
|
|
4695
|
+
const outside = draft || options?.draft ? [] : outsideLimits(state.content, limits, changes);
|
|
4696
|
+
if (outside.length > 0) {
|
|
4697
|
+
const from = state.revision;
|
|
4698
|
+
const description = [
|
|
4699
|
+
t("draft.reviewDescription"),
|
|
4700
|
+
"",
|
|
4701
|
+
...changes.map((change) => `- \`${change.path}\``)
|
|
4702
|
+
].join("\n");
|
|
4703
|
+
const submit = backendDrafts ? async () => {
|
|
4704
|
+
const review = await siteDrafts.create(newDraftName(), { from });
|
|
4705
|
+
await writeChanges(review.store, changes, {
|
|
4706
|
+
message,
|
|
4707
|
+
expectedRevision: await review.store.revision()
|
|
4708
|
+
});
|
|
4709
|
+
const { url } = await review.submitForReview({
|
|
4710
|
+
title: message,
|
|
4711
|
+
description,
|
|
4712
|
+
reviewers: await reviewers()
|
|
4713
|
+
});
|
|
4714
|
+
refreshDrafts(void 0);
|
|
4715
|
+
return { url };
|
|
4716
|
+
} : account?.review ? () => account.review?.(changes, {
|
|
4717
|
+
title: message,
|
|
4718
|
+
description,
|
|
4719
|
+
from
|
|
4720
|
+
}) ?? Promise.resolve({}) : void 0;
|
|
4721
|
+
await askLimits(outside, state.content, submit);
|
|
4722
|
+
return {
|
|
4723
|
+
ok: false,
|
|
4724
|
+
reason: "checks",
|
|
4725
|
+
error: void 0
|
|
4726
|
+
};
|
|
4727
|
+
}
|
|
3373
4728
|
const checks = options?.checks === false ? void 0 : await checkPublish(content, state, changes, {
|
|
3374
4729
|
blocks,
|
|
3375
4730
|
code
|
|
@@ -3424,7 +4779,13 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3424
4779
|
confirmChecks,
|
|
3425
4780
|
refreshDrafts,
|
|
3426
4781
|
loadFrom,
|
|
3427
|
-
onSignInError
|
|
4782
|
+
onSignInError,
|
|
4783
|
+
limits,
|
|
4784
|
+
backendDrafts,
|
|
4785
|
+
account,
|
|
4786
|
+
askLimits,
|
|
4787
|
+
reviewers,
|
|
4788
|
+
t
|
|
3428
4789
|
]);
|
|
3429
4790
|
const openDraft = useCallback(async (name) => {
|
|
3430
4791
|
if (name === draft?.name) return true;
|
|
@@ -3468,6 +4829,29 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3468
4829
|
reason: "error",
|
|
3469
4830
|
error: live.status === "error" && live.error
|
|
3470
4831
|
};
|
|
4832
|
+
const outside = outsideLimits(live.content, limitsFor(live.content.permissions, account?.user.login), changes);
|
|
4833
|
+
if (outside.length > 0) {
|
|
4834
|
+
const current = draft;
|
|
4835
|
+
const submit = backendDrafts ? async () => {
|
|
4836
|
+
const paths = changes.map((change) => change.path);
|
|
4837
|
+
return current.submitForReview({
|
|
4838
|
+
title: t("draft.reviewTitle", { name: current.name }),
|
|
4839
|
+
description: [
|
|
4840
|
+
t("draft.reviewDescription"),
|
|
4841
|
+
"",
|
|
4842
|
+
...paths.map((path) => `- \`${path}\``)
|
|
4843
|
+
].join("\n"),
|
|
4844
|
+
reviewers: await reviewers()
|
|
4845
|
+
});
|
|
4846
|
+
} : void 0;
|
|
4847
|
+
await askLimits(outside, live.content, submit);
|
|
4848
|
+
refreshDrafts(current);
|
|
4849
|
+
return {
|
|
4850
|
+
ok: false,
|
|
4851
|
+
reason: "checks",
|
|
4852
|
+
error: void 0
|
|
4853
|
+
};
|
|
4854
|
+
}
|
|
3471
4855
|
if (!await confirmChecks(await checkPublish(store, live, changes, {
|
|
3472
4856
|
blocks,
|
|
3473
4857
|
code
|
|
@@ -3504,7 +4888,12 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3504
4888
|
key,
|
|
3505
4889
|
t,
|
|
3506
4890
|
confirmChecks,
|
|
3507
|
-
onSignInError
|
|
4891
|
+
onSignInError,
|
|
4892
|
+
account,
|
|
4893
|
+
backendDrafts,
|
|
4894
|
+
askLimits,
|
|
4895
|
+
reviewers,
|
|
4896
|
+
refreshDrafts
|
|
3508
4897
|
]);
|
|
3509
4898
|
const submitDraft = useCallback(async () => {
|
|
3510
4899
|
if (!draft) return {
|
|
@@ -3519,8 +4908,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3519
4908
|
t("draft.reviewDescription"),
|
|
3520
4909
|
"",
|
|
3521
4910
|
...paths.map((path) => `- \`${path}\``)
|
|
3522
|
-
].join("\n")
|
|
4911
|
+
].join("\n"),
|
|
4912
|
+
reviewers: await reviewers()
|
|
3523
4913
|
});
|
|
4914
|
+
refreshDrafts(draft);
|
|
3524
4915
|
return {
|
|
3525
4916
|
ok: true,
|
|
3526
4917
|
url
|
|
@@ -3531,7 +4922,12 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3531
4922
|
error
|
|
3532
4923
|
};
|
|
3533
4924
|
}
|
|
3534
|
-
}, [
|
|
4925
|
+
}, [
|
|
4926
|
+
draft,
|
|
4927
|
+
t,
|
|
4928
|
+
reviewers,
|
|
4929
|
+
refreshDrafts
|
|
4930
|
+
]);
|
|
3535
4931
|
const discardDraft = useCallback(async () => {
|
|
3536
4932
|
if (!draft) return { ok: true };
|
|
3537
4933
|
try {
|
|
@@ -3550,6 +4946,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3550
4946
|
const drafts = useMemo(() => ({
|
|
3551
4947
|
kept: backendDrafts === void 0,
|
|
3552
4948
|
names: draftNames,
|
|
4949
|
+
reviews: draftReviews,
|
|
3553
4950
|
...draft && { current: {
|
|
3554
4951
|
name: draft.name,
|
|
3555
4952
|
paths: draftPaths
|
|
@@ -3561,6 +4958,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3561
4958
|
}), [
|
|
3562
4959
|
backendDrafts,
|
|
3563
4960
|
draftNames,
|
|
4961
|
+
draftReviews,
|
|
3564
4962
|
draft,
|
|
3565
4963
|
draftPaths,
|
|
3566
4964
|
openDraft,
|
|
@@ -3609,8 +5007,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3609
5007
|
state: "building"
|
|
3610
5008
|
});
|
|
3611
5009
|
}, [state, store]);
|
|
5010
|
+
const pluginInstalls = demo || contentOnly ? void 0 : account?.review ? "review" : atAdminAddress(site.admin) || site.broker !== void 0 ? "publish" : void 0;
|
|
3612
5011
|
const value = useMemo(() => ({
|
|
3613
5012
|
site,
|
|
5013
|
+
...limits && { limits },
|
|
3614
5014
|
blocks,
|
|
3615
5015
|
code,
|
|
3616
5016
|
preview,
|
|
@@ -3624,8 +5024,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3624
5024
|
drafts,
|
|
3625
5025
|
readFile,
|
|
3626
5026
|
listFiles,
|
|
5027
|
+
readBytes,
|
|
3627
5028
|
registries,
|
|
3628
5029
|
outOfReach,
|
|
5030
|
+
pluginInstalls,
|
|
3629
5031
|
demo,
|
|
3630
5032
|
pages,
|
|
3631
5033
|
versions,
|
|
@@ -3637,9 +5039,11 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3637
5039
|
scheduledBuilds,
|
|
3638
5040
|
repositoryVisibility,
|
|
3639
5041
|
scheduleSyncProblem,
|
|
3640
|
-
followBuild
|
|
5042
|
+
followBuild,
|
|
5043
|
+
tasks
|
|
3641
5044
|
}), [
|
|
3642
5045
|
site,
|
|
5046
|
+
limits,
|
|
3643
5047
|
blocks,
|
|
3644
5048
|
code,
|
|
3645
5049
|
preview,
|
|
@@ -3653,8 +5057,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3653
5057
|
drafts,
|
|
3654
5058
|
readFile,
|
|
3655
5059
|
listFiles,
|
|
5060
|
+
readBytes,
|
|
3656
5061
|
registries,
|
|
3657
5062
|
outOfReach,
|
|
5063
|
+
pluginInstalls,
|
|
3658
5064
|
demo,
|
|
3659
5065
|
pages,
|
|
3660
5066
|
versions,
|
|
@@ -3666,7 +5072,8 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3666
5072
|
scheduledBuilds,
|
|
3667
5073
|
repositoryVisibility,
|
|
3668
5074
|
scheduleSyncProblem,
|
|
3669
|
-
followBuild
|
|
5075
|
+
followBuild,
|
|
5076
|
+
tasks
|
|
3670
5077
|
]);
|
|
3671
5078
|
const table = useStringTable();
|
|
3672
5079
|
const strings = useMemo(() => draft ? {
|
|
@@ -3679,16 +5086,28 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
|
|
|
3679
5086
|
value,
|
|
3680
5087
|
children: /* @__PURE__ */ jsxs(StringsProvider, {
|
|
3681
5088
|
strings,
|
|
3682
|
-
children: [
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
5089
|
+
children: [
|
|
5090
|
+
state.status === "ready" ? /* @__PURE__ */ jsx(SiteFrames, {
|
|
5091
|
+
frames,
|
|
5092
|
+
state,
|
|
5093
|
+
onCodeClient,
|
|
5094
|
+
children
|
|
5095
|
+
}) : children,
|
|
5096
|
+
checking && /* @__PURE__ */ jsx(ChecksDialog, {
|
|
5097
|
+
checks: checking.checks,
|
|
5098
|
+
content: checking.content,
|
|
5099
|
+
onDecide: checking.decide
|
|
5100
|
+
}),
|
|
5101
|
+
limiting && /* @__PURE__ */ jsx(LimitsDialog, {
|
|
5102
|
+
problems: limiting.problems,
|
|
5103
|
+
content: limiting.content,
|
|
5104
|
+
...limiting.submit && { onSubmit: limiting.submit },
|
|
5105
|
+
onClose: () => {
|
|
5106
|
+
setLimiting(void 0);
|
|
5107
|
+
limiting.done();
|
|
5108
|
+
}
|
|
5109
|
+
})
|
|
5110
|
+
]
|
|
3692
5111
|
})
|
|
3693
5112
|
});
|
|
3694
5113
|
}
|
|
@@ -3711,7 +5130,7 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
|
|
|
3711
5130
|
const t = useStrings();
|
|
3712
5131
|
const strings = useStringTable();
|
|
3713
5132
|
const theme = useThemeChoice();
|
|
3714
|
-
const { preview, siteUrl, mediaPreviews, account, scheduledBuilds, repositoryVisibility, scheduleSyncProblem } = useAdmin();
|
|
5133
|
+
const { site, preview, siteUrl, mediaPreviews, account, scheduledBuilds, repositoryVisibility, scheduleSyncProblem, readFile, readBytes, listFiles, publish, tasks } = useAdmin();
|
|
3715
5134
|
const { timing, visibility } = useScheduleInfoOf(scheduledBuilds, repositoryVisibility);
|
|
3716
5135
|
const schedule = useMemo(() => ({
|
|
3717
5136
|
...timing && { timing },
|
|
@@ -3725,11 +5144,48 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
|
|
|
3725
5144
|
const siteCss = useSiteCss(preview.stylesheets);
|
|
3726
5145
|
const [choosing, setChoosing] = useState();
|
|
3727
5146
|
const confirmLeave = t("unsaved.confirm");
|
|
5147
|
+
const plugins = useMemo(() => site.description.plugins ?? [], [site.description.plugins]);
|
|
5148
|
+
const pluginHost = useMemo(() => ({
|
|
5149
|
+
editorUrl: frames.url,
|
|
5150
|
+
siteUrl,
|
|
5151
|
+
theme,
|
|
5152
|
+
read: readFile,
|
|
5153
|
+
readBytes,
|
|
5154
|
+
list: listFiles,
|
|
5155
|
+
publish: async (changes, message) => (await publish(changes, message)).ok,
|
|
5156
|
+
...account?.plugins && { server: account.plugins },
|
|
5157
|
+
navigate: (href) => navigate(href, () => window.confirm(confirmLeave)),
|
|
5158
|
+
secretsHelp: async (plugin) => pluginSecretsHelp(await tasks?.secretsPlace().catch(() => void 0), plugin, t),
|
|
5159
|
+
...tasks && { runTask: async (request) => {
|
|
5160
|
+
try {
|
|
5161
|
+
return await tasks.run(request);
|
|
5162
|
+
} catch (error) {
|
|
5163
|
+
if (error instanceof PluginTaskError) throw new Error(t(`plugins.task.${error.problem}`));
|
|
5164
|
+
throw error;
|
|
5165
|
+
}
|
|
5166
|
+
} }
|
|
5167
|
+
}), [
|
|
5168
|
+
frames.url,
|
|
5169
|
+
siteUrl,
|
|
5170
|
+
theme,
|
|
5171
|
+
readFile,
|
|
5172
|
+
readBytes,
|
|
5173
|
+
listFiles,
|
|
5174
|
+
publish,
|
|
5175
|
+
account,
|
|
5176
|
+
confirmLeave,
|
|
5177
|
+
tasks,
|
|
5178
|
+
t
|
|
5179
|
+
]);
|
|
5180
|
+
const callPlugin = useRef(void 0);
|
|
5181
|
+
const onPluginCall = useCallback((call) => {
|
|
5182
|
+
callPlugin.current = call;
|
|
5183
|
+
}, []);
|
|
3728
5184
|
const services = useMemo(() => ({
|
|
3729
5185
|
source: frames,
|
|
3730
5186
|
init: {
|
|
3731
5187
|
content: state.content,
|
|
3732
|
-
media: state.media,
|
|
5188
|
+
media: allMedia(state.content, state.media),
|
|
3733
5189
|
schedule,
|
|
3734
5190
|
siteUrl,
|
|
3735
5191
|
preview,
|
|
@@ -3748,7 +5204,8 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
|
|
|
3748
5204
|
};
|
|
3749
5205
|
});
|
|
3750
5206
|
}),
|
|
3751
|
-
navigate: (href) => navigate(href, () => window.confirm(confirmLeave))
|
|
5207
|
+
navigate: (href) => navigate(href, () => window.confirm(confirmLeave)),
|
|
5208
|
+
callPlugin: (plugin, method, args) => callPlugin.current ? callPlugin.current(plugin, method, args) : Promise.reject(/* @__PURE__ */ new Error("Plugins haven't started."))
|
|
3752
5209
|
}), [
|
|
3753
5210
|
frames,
|
|
3754
5211
|
state,
|
|
@@ -3765,7 +5222,12 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
|
|
|
3765
5222
|
return /* @__PURE__ */ jsxs(FrameServicesProvider, {
|
|
3766
5223
|
value: services,
|
|
3767
5224
|
children: [
|
|
3768
|
-
|
|
5225
|
+
/* @__PURE__ */ jsx(PluginServices, {
|
|
5226
|
+
plugins,
|
|
5227
|
+
host: pluginHost,
|
|
5228
|
+
onCall: onPluginCall,
|
|
5229
|
+
children
|
|
5230
|
+
}),
|
|
3769
5231
|
/* @__PURE__ */ jsx(SiteCodeFrame, { onClient: onCodeClient }),
|
|
3770
5232
|
choosing && /* @__PURE__ */ jsx(MediaPickerDialog, {
|
|
3771
5233
|
kind: choosing.kind,
|
|
@@ -3949,4 +5411,4 @@ function formValues(collection, entry) {
|
|
|
3949
5411
|
return values;
|
|
3950
5412
|
}
|
|
3951
5413
|
//#endregion
|
|
3952
|
-
export {
|
|
5414
|
+
export { PublishedNotice as $, useRoute as A, pageFileChange as At, mayChange as B, editedMarkdown as Bt, AppLink as C, layoutFileChange as Ct, hasUnsavedChanges as D, movePageChanges as Dt, entryEditorHref as E, moveEntryChanges as Et, PluginScreenFrame as F, storedCss as Ft, useFrameServices as G, FrameServicesProvider as H, usePluginFrame as I, storedData as It, draftStarted as J, useFrameUpdates as K, usePluginHost as L, storedEntryFields as Lt, prepareUpload as M, redirectsFileChange as Mt, useOutsideHost as N, siteSettingsFileChange as Nt, navigate as O, newCollectionSettings as Ot, PublishFailure as P, slugify as Pt, Field as Q, createMediaPreviews as R, uniqueName as Rt, MediaScreen as S, entryFileChange$1 as St, collectionHref as T, menusFileChange as Tt, reloadFrames as U, useMayChange as V, formattedHtml as Vt, useFrame as W, Dialog as X, Button as Y, ErrorMessage as Z, scheduleSettingsChanges as _, collectionFileChange$1 as _t, AdminContext as a, useStrings as at, MediaChooser as b, deleteCollectionChanges as bt, useCanChangeSettings as c, allMedia as ct, useApplyThemeChoice as d, siteImages as dt, TextField as et, useDarkMode as f, uploadName as ft, ScheduleStatus as g, codeFileChanges as gt, ScheduleFields as h, checkPageAddress as ht, usePreviewStyles as i, useStringTable as it, useUnsavedChanges as j, pageTitle$1 as jt, pageEditorHref as k, newPageData as kt, useSiteContent as l, fileName as lt, ComingUp as m, checkEntrySlug as mt, formValues as n, StringsProvider as nt, AdminProvider as o, describeContrast as ot, useThemeChoice as p, addressPatternFor as pt, READY_MESSAGE as q, PreviewFrame as r, defaultStrings as rt, useAdmin as s, findPage as st, SCHEDULE_PROP as t, useDebounced as tt, setThemeChoice as u, mediaUrl as ut, REGISTRY_VERSION as v, collectionSettingsChanges as vt, SETTINGS_SECTIONS as w, mediaFileChange as wt, MediaField as x, duplicatePageData as xt, MediaChoiceContext as y, customCssFileChange as yt, showMediaInPreview as z, updateSettingsFileChange as zt };
|