@morphemecms/cms 0.9.0-next-20261105115842 → 0.9.1
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/CHANGELOG.md +24 -1
- package/defaults/collections.ts +112 -58
- package/defaults/company.ts +1 -1
- package/defaults/editor.ts +2 -2
- package/defaults/texts.ts +14 -5
- package/demo/src/content/pages/app-store.md +0 -1
- package/demo/src/content/pages/blocks.md +0 -1
- package/icons/lucide/arrows-horizontal.svg +1 -0
- package/icons/lucide/columns-plus-left.svg +1 -0
- package/icons/lucide/columns-plus-right.svg +1 -0
- package/icons/lucide/file.svg +1 -0
- package/icons/material-symbols/arrows-horizontal.svg +1 -0
- package/icons/material-symbols/columns-plus-left.svg +1 -0
- package/icons/material-symbols/columns-plus-right.svg +1 -0
- package/icons/material-symbols/file.svg +1 -0
- package/icons/phosphor/arrows-horizontal.svg +1 -0
- package/icons/phosphor/columns-plus-left.svg +1 -0
- package/icons/phosphor/columns-plus-right.svg +1 -0
- package/icons/phosphor/file.svg +1 -0
- package/lib/fold-open.d.mts +2 -0
- package/lib/fold-open.mjs +22 -0
- package/lib/fold-open.test.mjs +24 -0
- package/lib/menu.test.mjs +16 -3
- package/lib/page-template.d.mts +54 -0
- package/lib/page-template.mjs +126 -0
- package/lib/page-template.test.mjs +129 -0
- package/lib/side-block.d.mts +2 -16
- package/lib/side-block.mjs +12 -30
- package/lib/side-block.test.mjs +13 -30
- package/package.json +1 -1
- package/scripts/check-side.mjs +49 -22
- package/scripts/editor-roundtrip.mjs +15 -6
- package/scripts/fixtures/side/side-full.md +8 -0
- package/scripts/fixtures/side/side-left.md +8 -0
- package/scripts/fixtures/side/side-none.md +7 -0
- package/scripts/fixtures/side/side-right.md +8 -0
- package/scripts/fixtures/side/side-single.md +8 -0
- package/scripts/side-width.mjs +29 -0
- package/scripts/side-width.test.mjs +30 -0
- package/site/content.config.ts +3 -4
- package/site/header.d.mts +21 -0
- package/site/header.mjs +35 -0
- package/site/header.test.mjs +59 -0
- package/site/icons.d.mts +16 -0
- package/site/icons.mjs +8 -0
- package/site/site.ts +9 -4
- package/site/templates.ts +7 -0
- package/site/theme.mjs +3 -0
- package/site/tracking.client.ts +4 -4
- package/src/App.tsx +12 -24
- package/src/AppList.tsx +36 -14
- package/src/AppSettingsTab.tsx +5 -3
- package/src/BlockDialog.tsx +50 -5
- package/src/DataFileFields.tsx +35 -0
- package/src/DataForm.tsx +16 -38
- package/src/Deleted.tsx +7 -3
- package/src/EntryForm.tsx +41 -88
- package/src/FooterSettings.tsx +76 -0
- package/src/FormFrame.tsx +2 -1
- package/src/HeaderSettings.tsx +100 -0
- package/src/Library.tsx +26 -14
- package/src/MarkdownEditor.tsx +18 -36
- package/src/Media.tsx +6 -3
- package/src/Redirects.tsx +12 -9
- package/src/SectionSettings.tsx +21 -0
- package/src/SectionSettingsIcon.tsx +10 -0
- package/src/SettingsSections.tsx +57 -0
- package/src/ThemeStore.tsx +28 -9
- package/src/Versions.tsx +3 -15
- package/src/busyAction.d.mts +3 -0
- package/src/busyAction.mjs +8 -0
- package/src/busyAction.test.mjs +16 -0
- package/src/dataFileValues.ts +30 -0
- package/src/editor.css +42 -3
- package/src/fields.tsx +29 -10
- package/src/footerPreview.d.mts +46 -0
- package/src/footerPreview.mjs +84 -0
- package/src/footerPreview.test.mjs +100 -0
- package/src/formSections.ts +100 -0
- package/src/frontmatter.mjs +1 -1
- package/src/frontmatter.test.mjs +11 -11
- package/src/headerPreview.d.mts +48 -0
- package/src/headerPreview.mjs +168 -0
- package/src/headerPreview.test.mjs +183 -0
- package/src/headerTheme.tsx +58 -0
- package/src/i18n/da-DK/common.ts +1 -0
- package/src/i18n/da-DK/library.ts +2 -0
- package/src/i18n/da-DK/preview.ts +2 -0
- package/src/i18n/da-DK/text.ts +1 -0
- package/src/i18n/en-US/common.ts +1 -0
- package/src/i18n/en-US/library.ts +4 -0
- package/src/i18n/en-US/preview.ts +3 -0
- package/src/i18n/en-US/text.ts +2 -1
- package/src/images.ts +7 -2
- package/src/menuChildren.ts +1 -3
- package/src/movedRoutes.d.mts +3 -0
- package/src/movedRoutes.mjs +35 -0
- package/src/movedRoutes.test.mjs +22 -0
- package/src/pagePreview.tsx +40 -29
- package/src/partPreview.tsx +59 -0
- package/src/schema.ts +7 -1
- package/src/sheetPlacement.d.mts +3 -0
- package/src/sheetPlacement.mjs +9 -0
- package/src/sheetPlacement.test.mjs +23 -0
- package/src/standard/views.ts +3 -3
- package/src/templateField.ts +28 -0
- package/src/themes.ts +3 -0
- package/src/ui.tsx +35 -8
- package/src/useDataFiles.ts +94 -0
- package/starter/package.json +1 -1
- package/theme/components/Footer.astro +14 -7
- package/theme/components/Header.astro +58 -33
- package/theme/components/MobileNav.astro +5 -4
- package/theme/components/RelatedPosts.astro +1 -1
- package/theme/components/headerParts.ts +17 -0
- package/theme/layouts/PageLayout.astro +20 -16
- package/theme/styles/base.css +6 -5
- package/theme/styles/blocks.css +8 -12
- package/theme/styles/components.css +8 -1
- package/theme/styles/global.css +5 -9
- package/theme/styles/ui.css +111 -5
- package/theme/theme.json +1 -0
- package/theme/ui/BusyContent.tsx +16 -0
- package/theme/ui/Button.tsx +25 -5
- package/theme/ui/IconButton.tsx +17 -4
- package/scripts/fixtures/side/side-off.md +0 -8
- package/scripts/fixtures/side/side-plain.md +0 -7
- package/scripts/fixtures/side/side-wide.md +0 -8
package/src/AppList.tsx
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// the addresses it loads from and sends forms to, its part of the Worker, a step in the build), the cookies it sets (and
|
|
17
17
|
// whether the cookie banner asks first), who made it (a row to their page, #apps/udvikler/<name>, with all their apps),
|
|
18
18
|
// and where it is used); one not installed has no tabs; installing, updating and uninstalling are each saved at once in its own commit,
|
|
19
|
-
// as a block turned on or off under "Blokindstillinger". One of the site's own is installed or uninstalled in the site's
|
|
19
|
+
// as a block turned on or off under "Blokindstillinger". The button pressed shows the loading indicator while it runs (busyAction.mjs). One of the site's own is installed or uninstalled in the site's
|
|
20
20
|
// file of apps; one from the store is copied into the site, or deleted from it, with it (appChanges in store.ts), and
|
|
21
21
|
// its page before it is installed is drawn from the store's catalog (StoreAppPage). Installed, its blocks and kinds are
|
|
22
22
|
// under "Blokindstillinger" (under its name), its blocks under "Indsæt" and its kinds under "+" of their list. An app in
|
|
@@ -82,6 +82,7 @@ import AppAdds from './AppAdds';
|
|
|
82
82
|
import AppFormPage from './AppFormPage';
|
|
83
83
|
import AppSettingsTab from './AppSettingsTab';
|
|
84
84
|
import { INFO_KEY } from '../lib/appRoute.mjs';
|
|
85
|
+
import { busyFor } from './busyAction.mjs';
|
|
85
86
|
|
|
86
87
|
export const appHref = (app: SiteApp) => `#apps/${app.id}`;
|
|
87
88
|
|
|
@@ -670,23 +671,28 @@ export async function autoUpdate(env: Env, files: RepoFile[]) {
|
|
|
670
671
|
}
|
|
671
672
|
}
|
|
672
673
|
|
|
674
|
+
type StoreAction = 'install' | 'update' | 'uninstall';
|
|
675
|
+
|
|
673
676
|
/** Installing, updating and uninstalling an app from the store, each saved at once in its own commit: its folder
|
|
674
677
|
* copied from the store, or deleted, with the site's file of apps (appChanges in store.ts). Each says true when it
|
|
675
678
|
* was saved. */
|
|
676
679
|
function useStoreActions(env: Env, files: RepoFile[]) {
|
|
677
|
-
|
|
680
|
+
// Which action runs, on which app; `asking` while a dialog waits for the user (busyAction.mjs shows only the pressed button).
|
|
681
|
+
const [running, setRunning] = useState<{ id: string; action: StoreAction; asking: boolean } | null>(null);
|
|
682
|
+
const saving = !!running;
|
|
678
683
|
const installed = installedApps(files);
|
|
679
684
|
const file = files.find((f) => f.path === config.apps.installed);
|
|
680
685
|
|
|
681
686
|
async function change(
|
|
682
687
|
id: string,
|
|
688
|
+
action: StoreAction,
|
|
683
689
|
// `kind`: the notification it is when it is saved (an update is "Apps opdateret", as one on its own).
|
|
684
690
|
text: { busy: string; done: string; commit: string; change: string; kind?: 'saved' | 'updates' },
|
|
685
691
|
next: () => Promise<Parameters<typeof appChanges>[2]>,
|
|
686
692
|
// Whether to go on, with the app's files in the site now (an update asks, when some were changed).
|
|
687
693
|
go: (current: RepoFile[]) => Promise<boolean> = async () => true,
|
|
688
694
|
): Promise<boolean> {
|
|
689
|
-
|
|
695
|
+
setRunning({ id, action, asking: false });
|
|
690
696
|
env.notify({ text: text.busy });
|
|
691
697
|
try {
|
|
692
698
|
const [current, wanted] = await Promise.all([env.folder(appFolder(id)), next()]);
|
|
@@ -694,6 +700,9 @@ function useStoreActions(env: Env, files: RepoFile[]) {
|
|
|
694
700
|
env.notify(null);
|
|
695
701
|
return false;
|
|
696
702
|
}
|
|
703
|
+
// `go` cleared the message (and its dialog stopped the button being busy): saving goes on.
|
|
704
|
+
setRunning({ id, action, asking: false });
|
|
705
|
+
env.notify({ text: text.busy });
|
|
697
706
|
const cookies = cookieChanges(id, wanted ? cookiesIn(wanted.files) : null, files);
|
|
698
707
|
await saveApp(env, text.commit, text.change, [...appChanges(id, current, wanted, installed, file), ...cookies], file);
|
|
699
708
|
env.notify({ text: text.done, kind: text.kind ?? 'saved' });
|
|
@@ -702,15 +711,17 @@ function useStoreActions(env: Env, files: RepoFile[]) {
|
|
|
702
711
|
env.notify({ text: e instanceof ConflictError ? e.message : t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
|
|
703
712
|
return false;
|
|
704
713
|
} finally {
|
|
705
|
-
|
|
714
|
+
setRunning(null);
|
|
706
715
|
}
|
|
707
716
|
}
|
|
708
717
|
|
|
709
718
|
return {
|
|
710
719
|
saving,
|
|
720
|
+
busy: (id: string, action: StoreAction) => busyFor(running, id, action),
|
|
711
721
|
install: (app: ShownApp) =>
|
|
712
722
|
change(
|
|
713
723
|
app.id,
|
|
724
|
+
'install',
|
|
714
725
|
{
|
|
715
726
|
busy: t.apps.installing,
|
|
716
727
|
done: t.apps.isInstalledSoon(app.label),
|
|
@@ -722,6 +733,7 @@ function useStoreActions(env: Env, files: RepoFile[]) {
|
|
|
722
733
|
async update(app: ShownApp, record: StoreRecord) {
|
|
723
734
|
const done = await change(
|
|
724
735
|
app.id,
|
|
736
|
+
'update',
|
|
725
737
|
{
|
|
726
738
|
busy: t.apps.updating,
|
|
727
739
|
done: t.apps.isUpdatedSoon(app.label, app.version),
|
|
@@ -732,6 +744,7 @@ function useStoreActions(env: Env, files: RepoFile[]) {
|
|
|
732
744
|
async () => ({ files: await env.store.files(app), record: recordOf(app) }),
|
|
733
745
|
async (current) => {
|
|
734
746
|
env.notify(null);
|
|
747
|
+
setRunning({ id: app.id, action: 'update', asking: true });
|
|
735
748
|
return askUpdate(
|
|
736
749
|
app.label,
|
|
737
750
|
record.version,
|
|
@@ -747,6 +760,7 @@ function useStoreActions(env: Env, files: RepoFile[]) {
|
|
|
747
760
|
uninstall: (id: string, label: string, written: string) =>
|
|
748
761
|
change(
|
|
749
762
|
id,
|
|
763
|
+
'uninstall',
|
|
750
764
|
{
|
|
751
765
|
busy: t.apps.uninstalling,
|
|
752
766
|
done: t.apps.isUninstalled(label),
|
|
@@ -839,7 +853,7 @@ function useStorePictures(env: Env, app: ShownApp | undefined) {
|
|
|
839
853
|
* catalog: as an app's page in the site, what it adds without switches, and "Installer" (or "Afinstaller"). */
|
|
840
854
|
export function StoreAppPage({ id, env, files }: { id: string; env: Env; files: RepoFile[] }) {
|
|
841
855
|
const catalog = useCatalog(env);
|
|
842
|
-
const { saving, install, uninstall } = useStoreActions(env, files);
|
|
856
|
+
const { saving, busy, install, uninstall } = useStoreActions(env, files);
|
|
843
857
|
const pending = installedApps(files).includes(id);
|
|
844
858
|
const app = Array.isArray(catalog) ? catalog.find((a) => a.id === id) : undefined;
|
|
845
859
|
const pictures = useStorePictures(env, app);
|
|
@@ -863,7 +877,7 @@ export function StoreAppPage({ id, env, files }: { id: string; env: Env; files:
|
|
|
863
877
|
/>
|
|
864
878
|
{!pending && (
|
|
865
879
|
<div className="mb-8">
|
|
866
|
-
<Button disabled={saving} onClick={() => install(app)}>
|
|
880
|
+
<Button disabled={saving} busy={busy(app.id, 'install')} onClick={() => install(app)}>
|
|
867
881
|
{t.apps.install}
|
|
868
882
|
</Button>
|
|
869
883
|
</div>
|
|
@@ -900,8 +914,8 @@ export function StoreAppPage({ id, env, files }: { id: string; env: Env; files:
|
|
|
900
914
|
<div>
|
|
901
915
|
<Button
|
|
902
916
|
kind="outlined"
|
|
903
|
-
|
|
904
917
|
disabled={saving}
|
|
918
|
+
busy={busy(id, 'uninstall')}
|
|
905
919
|
onClick={async () => {
|
|
906
920
|
const label = app?.label ?? id;
|
|
907
921
|
if (await ask(t.apps.askUninstall(label), t.apps.uninstall)) uninstall(id, label, app?.written ?? id);
|
|
@@ -978,9 +992,9 @@ export function AppScreenPage({ app, id, env }: { app: SiteApp; id: string; env:
|
|
|
978
992
|
* not installed, with the pictures, what it needs, its cookies, who made it and where it is used). */
|
|
979
993
|
function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab; fields?: ReactNode }) {
|
|
980
994
|
const notify = env.notify;
|
|
981
|
-
const [toggling, setToggling] = useState(
|
|
995
|
+
const [toggling, setToggling] = useState<'install' | 'uninstall' | null>(null);
|
|
982
996
|
const store = useStoreActions(env, files);
|
|
983
|
-
const saving = toggling || store.saving;
|
|
997
|
+
const saving = !!toggling || store.saving;
|
|
984
998
|
const catalog = useCatalog(env);
|
|
985
999
|
const update = updateOf(app, catalog);
|
|
986
1000
|
// Its release notes as the store has them: also those of a version the site's build does not have yet.
|
|
@@ -1001,7 +1015,7 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
|
|
|
1001
1015
|
const next = installed.filter((id) => id !== app.id).concat(yes ? [app.id] : []);
|
|
1002
1016
|
const text = `${JSON.stringify({ installed: [...new Set(next)].sort() }, null, 2)}\n`;
|
|
1003
1017
|
if (text === file?.text) return;
|
|
1004
|
-
setToggling(
|
|
1018
|
+
setToggling(yes ? 'install' : 'uninstall');
|
|
1005
1019
|
notify({ text: yes ? t.apps.installing : t.apps.uninstalling });
|
|
1006
1020
|
try {
|
|
1007
1021
|
// With its cookies added to the cookie list, or those it added taken out.
|
|
@@ -1015,7 +1029,7 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
|
|
|
1015
1029
|
} catch (e) {
|
|
1016
1030
|
notify({ text: e instanceof ConflictError ? e.message : t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
|
|
1017
1031
|
} finally {
|
|
1018
|
-
setToggling(
|
|
1032
|
+
setToggling(null);
|
|
1019
1033
|
}
|
|
1020
1034
|
}
|
|
1021
1035
|
|
|
@@ -1032,11 +1046,11 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
|
|
|
1032
1046
|
{(!on || (update && app.store)) && (
|
|
1033
1047
|
<div className="mb-8">
|
|
1034
1048
|
{on ? (
|
|
1035
|
-
<Button disabled={saving} onClick={() => store.update(update!, app.store!)}>
|
|
1049
|
+
<Button disabled={saving} busy={store.busy(app.id, 'update')} onClick={() => store.update(update!, app.store!)}>
|
|
1036
1050
|
{t.apps.update(update!.version)}
|
|
1037
1051
|
</Button>
|
|
1038
1052
|
) : (
|
|
1039
|
-
<Button disabled={saving} onClick={() => install(true)}>
|
|
1053
|
+
<Button disabled={saving} busy={toggling === 'install' || store.busy(app.id, 'install')} onClick={() => install(true)}>
|
|
1040
1054
|
{t.apps.install}
|
|
1041
1055
|
</Button>
|
|
1042
1056
|
)}
|
|
@@ -1045,7 +1059,15 @@ function AppContent({ app, env, files, tab, fields }: AppProps & { tab: AppTab;
|
|
|
1045
1059
|
{on && tab === 'settings' ? (
|
|
1046
1060
|
<>
|
|
1047
1061
|
{fields}
|
|
1048
|
-
<AppSettingsTab
|
|
1062
|
+
<AppSettingsTab
|
|
1063
|
+
app={app}
|
|
1064
|
+
env={env}
|
|
1065
|
+
files={files}
|
|
1066
|
+
busy={saving}
|
|
1067
|
+
uninstalling={toggling === 'uninstall' || store.busy(app.id, 'uninstall')}
|
|
1068
|
+
stuck={stuck}
|
|
1069
|
+
onUninstall={() => install(false)}
|
|
1070
|
+
/>
|
|
1049
1071
|
</>
|
|
1050
1072
|
) : (
|
|
1051
1073
|
<AppInfoTab
|
package/src/AppSettingsTab.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The Settings tab of an installed app's page, below its head and its settings form: what the app shows itself (`page`),
|
|
2
|
-
// what it adds with its switches, its kinds of notifications with their switches, and "Afinstaller" with its text.
|
|
2
|
+
// what it adds with its switches, its kinds of notifications with their switches, and "Afinstaller" with its text (it shows the loading indicator while it uninstalls).
|
|
3
3
|
// Exposes AppSettingsTab (default). Depends on AppAdds.tsx, AppNotificationSwitches.tsx, ./ui and notificationData.ts.
|
|
4
4
|
import { Button, SupportingText } from './ui';
|
|
5
5
|
import AppAdds from './AppAdds';
|
|
@@ -10,12 +10,13 @@ import type { SiteApp } from './apps';
|
|
|
10
10
|
import { appType } from './notificationData';
|
|
11
11
|
import { siteText, t } from './i18n';
|
|
12
12
|
|
|
13
|
-
/** `busy`: something is saving; `stuck`: the app is in use, so it cannot be uninstalled; `onUninstall`: "Afinstaller". */
|
|
13
|
+
/** `busy`: something is saving; `uninstalling`: this app is uninstalled now; `stuck`: the app is in use, so it cannot be uninstalled; `onUninstall`: "Afinstaller". */
|
|
14
14
|
export default function AppSettingsTab({
|
|
15
15
|
app,
|
|
16
16
|
env,
|
|
17
17
|
files,
|
|
18
18
|
busy,
|
|
19
|
+
uninstalling,
|
|
19
20
|
stuck,
|
|
20
21
|
onUninstall,
|
|
21
22
|
}: {
|
|
@@ -23,6 +24,7 @@ export default function AppSettingsTab({
|
|
|
23
24
|
env: Env;
|
|
24
25
|
files: RepoFile[];
|
|
25
26
|
busy: boolean;
|
|
27
|
+
uninstalling: boolean;
|
|
26
28
|
stuck: boolean;
|
|
27
29
|
onUninstall: () => void;
|
|
28
30
|
}) {
|
|
@@ -42,7 +44,7 @@ export default function AppSettingsTab({
|
|
|
42
44
|
{notifies.length > 0 && <AppNotificationSwitches notifies={notifies} />}
|
|
43
45
|
{/* As wide as the fields and the rows above. */}
|
|
44
46
|
<div>
|
|
45
|
-
<Button kind="outlined" disabled={busy || stuck} onClick={onUninstall}>
|
|
47
|
+
<Button kind="outlined" disabled={busy || stuck} busy={uninstalling} onClick={onUninstall}>
|
|
46
48
|
{t.apps.uninstall}
|
|
47
49
|
</Button>
|
|
48
50
|
<SupportingText className="mt-2">{stuck ? t.apps.stuck : t.apps.uninstallHint}</SupportingText>
|
package/src/BlockDialog.tsx
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// so it must be inside the form's Fields.Provider. list: the blocks of this text (the site's blocks on a
|
|
5
5
|
// page, the sections on a fixed page). Over the blocks to choose from, as under "Standardblokke" (Library.tsx):
|
|
6
6
|
// the search, the order (A–Å, Å–A or by group, kept in this browser) and the funnel with the filter by group
|
|
7
|
-
// (the standard blocks, each app's under its name), the one picked as a chip.
|
|
7
|
+
// (the standard blocks, each app's under its name), the one picked as a chip. Also the sheet of a section's
|
|
8
|
+
// settings (its colour, SectionSettings.tsx), which goes the same way as a block's fields: Annuller, Gem, the question when closed changed.
|
|
9
|
+
// On a large screen the fields and the section settings are in a side panel instead of the dialog (sheetPlacement.mjs, .editor-side-panel).
|
|
8
10
|
import { useEffect, useRef, useState, type ReactElement } from 'react';
|
|
9
11
|
import { ArrowsDownUp, Image } from '@phosphor-icons/react';
|
|
10
12
|
import {
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
ProblemNote,
|
|
24
26
|
SheetList,
|
|
25
27
|
SheetGroupTitle,
|
|
28
|
+
useAtLeast,
|
|
26
29
|
} from './ui';
|
|
27
30
|
import { htmlBlock, type BlockData, type BlockProps, type SiteBlock } from './blocks';
|
|
28
31
|
import { clean, newValue, problemAt, savedValue } from './check';
|
|
@@ -36,6 +39,8 @@ import CropField, { type Cut } from './Crop';
|
|
|
36
39
|
import { t } from './i18n';
|
|
37
40
|
import { snackbar } from '../lib/snackbar';
|
|
38
41
|
import { closeChanged } from './ask';
|
|
42
|
+
import SectionSettings from './SectionSettings';
|
|
43
|
+
import { inPanel } from './sheetPlacement.mjs';
|
|
39
44
|
|
|
40
45
|
export type BlockAsk = {
|
|
41
46
|
list: SiteBlock[];
|
|
@@ -49,9 +54,14 @@ export type BlockAsk = {
|
|
|
49
54
|
choose: (offer?: { image?: boolean; have?: string[] }) => Promise<{ type: string; props: BlockProps } | null>;
|
|
50
55
|
/** New props for a block in the text; null when cancelled. isNew: one about to be put in ("Indsæt"). */
|
|
51
56
|
edit: (type: string, props: BlockProps, isNew?: boolean) => Promise<BlockProps | null>;
|
|
57
|
+
/** The settings of the section the cursor is in (now its colour; null: none); the new ones, or null when cancelled. */
|
|
58
|
+
section: (color: string | null) => Promise<{ color: string | null } | null>;
|
|
52
59
|
};
|
|
53
60
|
|
|
54
|
-
type Open = { step: 'choose'; image?: boolean; have?: string[] } | { step: 'fields'; type: string; isNew: boolean };
|
|
61
|
+
type Open = { step: 'choose'; image?: boolean; have?: string[] } | { step: 'fields'; type: string; isNew: boolean } | { step: 'section' };
|
|
62
|
+
|
|
63
|
+
/** The type a section's settings are answered with (not a block's). */
|
|
64
|
+
const SECTION = 'section';
|
|
55
65
|
|
|
56
66
|
/** The choice of a picture in the sheet ("+" in the toolbar, MarkdownEditor.tsx), and a picture in the
|
|
57
67
|
* text, which is changed here like a block's fields: another from the gallery, and its description, or
|
|
@@ -108,6 +118,7 @@ export function useBlockDialog(
|
|
|
108
118
|
): [BlockAsk, ReactElement] {
|
|
109
119
|
const blockOf = (type: string) => (type === IMAGE ? picture : type === HTML ? htmlBlock : list.find((b) => b.type === type));
|
|
110
120
|
const dialog = useRef<HTMLDialogElement>(null);
|
|
121
|
+
const large = useAtLeast('large');
|
|
111
122
|
const answer = useRef<(a: Answer) => void>(() => {});
|
|
112
123
|
const [open, setOpen] = useState<Open | null>(null);
|
|
113
124
|
const [values, setValues] = useState<BlockProps>({});
|
|
@@ -137,6 +148,7 @@ export function useBlockDialog(
|
|
|
137
148
|
setOpen(first);
|
|
138
149
|
setValues(props);
|
|
139
150
|
if (first.step === 'fields') shown.current = asWritten(first.type, props);
|
|
151
|
+
if (first.step === 'section') shown.current = JSON.stringify(props.color ?? '');
|
|
140
152
|
setProblem('');
|
|
141
153
|
wrongField.stop(null);
|
|
142
154
|
setQuery('');
|
|
@@ -163,6 +175,10 @@ export function useBlockDialog(
|
|
|
163
175
|
template,
|
|
164
176
|
data,
|
|
165
177
|
choose: (offer) => ask({ step: 'choose', image: offer?.image, have: offer?.have }),
|
|
178
|
+
section: async (color) => {
|
|
179
|
+
const answered = await ask({ step: 'section' }, { color: color ?? '' });
|
|
180
|
+
return answered && { color: answered.props.color ? String(answered.props.color) : null };
|
|
181
|
+
},
|
|
166
182
|
edit: async (type, props, isNew = false) => {
|
|
167
183
|
const fields = blockOf(type)?.fields ?? [];
|
|
168
184
|
// A block on the page with what a field it has no value for means; a new one with the defaults.
|
|
@@ -193,12 +209,23 @@ export function useBlockDialog(
|
|
|
193
209
|
// Closed (Annuller, the cross, a swipe down, Esc, a tap beside it) with the fields changed: the owner is
|
|
194
210
|
// asked whether to keep them ("Indsæt", "Gem"), leave them or stay.
|
|
195
211
|
async function leave() {
|
|
212
|
+
if (open?.step === 'section') {
|
|
213
|
+
if (JSON.stringify(values.color ?? '') === shown.current) return done(null);
|
|
214
|
+
const choice = await closeChanged(t.common.save);
|
|
215
|
+
if (choice === 'keep') saveSection();
|
|
216
|
+
else if (choice === 'discard') done(null);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
196
219
|
if (open?.step !== 'fields' || asWritten(open.type, values) === shown.current) return done(null);
|
|
197
220
|
const choice = await closeChanged(open.isNew ? t.blocks.insert : t.common.save);
|
|
198
221
|
if (choice === 'keep') save(open.type);
|
|
199
222
|
else if (choice === 'discard') done(null);
|
|
200
223
|
}
|
|
201
224
|
|
|
225
|
+
function saveSection() {
|
|
226
|
+
done({ type: SECTION, props: { color: values.color ?? '' } });
|
|
227
|
+
}
|
|
228
|
+
|
|
202
229
|
function pickSort(next: InsertSort) {
|
|
203
230
|
setSort(next);
|
|
204
231
|
try {
|
|
@@ -242,10 +269,19 @@ export function useBlockDialog(
|
|
|
242
269
|
.map((g) => ({ key: g as string | null, items: found.filter((b) => groupOf(b) === g) }))
|
|
243
270
|
.filter((g) => g.items.length > 0)
|
|
244
271
|
: headed(found, (b) => letterOf(b.label));
|
|
272
|
+
const panel = inPanel(open?.step, large);
|
|
273
|
+
const title = open?.step === 'section' ? t.text.sectionSettings : block ? block.label : t.blocks.insert;
|
|
274
|
+
// While the panel is open, the page makes room for it (editor.css: [data-side-panel]).
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
if (!panel) return;
|
|
277
|
+
document.documentElement.setAttribute('data-side-panel', '');
|
|
278
|
+
return () => document.documentElement.removeAttribute('data-side-panel');
|
|
279
|
+
}, [panel]);
|
|
245
280
|
const element = (
|
|
246
281
|
<BottomSheet
|
|
247
282
|
ref={dialog}
|
|
248
|
-
aria-label={
|
|
283
|
+
aria-label={title}
|
|
284
|
+
panel={panel}
|
|
249
285
|
// React passes on the close of a sheet inside it (the categories, a page): only its own counts.
|
|
250
286
|
onClose={(e) => e.target === e.currentTarget && answer.current(null)}
|
|
251
287
|
onCancel={(e) => {
|
|
@@ -253,9 +289,10 @@ export function useBlockDialog(
|
|
|
253
289
|
e.preventDefault();
|
|
254
290
|
void leave();
|
|
255
291
|
}}
|
|
256
|
-
|
|
292
|
+
// A tap beside a dialog leaves it; beside the panel is the page, which is inert while it is open.
|
|
293
|
+
onClick={(e) => !panel && e.target === dialog.current && void leave()}
|
|
257
294
|
>
|
|
258
|
-
<SheetTitle title={
|
|
295
|
+
<SheetTitle title={title} onClose={() => void leave()} />
|
|
259
296
|
{open?.step === 'choose' && (
|
|
260
297
|
<>
|
|
261
298
|
<SearchRow
|
|
@@ -327,6 +364,14 @@ export function useBlockDialog(
|
|
|
327
364
|
</SheetList>
|
|
328
365
|
</>
|
|
329
366
|
)}
|
|
367
|
+
{open?.step === 'section' && (
|
|
368
|
+
<>
|
|
369
|
+
<SheetBody>
|
|
370
|
+
<SectionSettings color={String(values.color ?? '')} onChange={(color) => setValues({ color })} />
|
|
371
|
+
</SheetBody>
|
|
372
|
+
<SheetActions onCancel={() => void leave()} onDone={saveSection} done={t.common.save} />
|
|
373
|
+
</>
|
|
374
|
+
)}
|
|
330
375
|
{block && open?.step === 'fields' && (
|
|
331
376
|
<>
|
|
332
377
|
<SheetBody>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A single data file's fields in a form: its own ("Aktiveret") and, when it is a list (the menus), its items as the menu
|
|
2
|
+
// (MenuList). DataForm.tsx draws a menu with them, and Appearance › Header (HeaderSettings.tsx) both menus. Exposes
|
|
3
|
+
// DataFileFields. Uses fields.tsx (FieldList, MenuList).
|
|
4
|
+
import type { DataFile } from './schema';
|
|
5
|
+
import { FieldList, MenuList } from './fields';
|
|
6
|
+
|
|
7
|
+
export function DataFileFields({
|
|
8
|
+
dataFile,
|
|
9
|
+
value,
|
|
10
|
+
onChange,
|
|
11
|
+
add,
|
|
12
|
+
}: {
|
|
13
|
+
dataFile: DataFile;
|
|
14
|
+
value: Record<string, unknown>;
|
|
15
|
+
onChange: (v: Record<string, unknown>) => void;
|
|
16
|
+
/** "Tilføj …" under the list (MenuList `add`), where the top bar has no "+" for it. */
|
|
17
|
+
add?: boolean;
|
|
18
|
+
}) {
|
|
19
|
+
const root = dataFile.root;
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
{dataFile.fields.length > 0 && (
|
|
23
|
+
<FieldList fields={dataFile.fields} value={value} onChange={(v) => onChange(v as Record<string, unknown>)} />
|
|
24
|
+
)}
|
|
25
|
+
{root && (
|
|
26
|
+
<MenuList
|
|
27
|
+
field={root}
|
|
28
|
+
value={(value[root.name] ?? []) as unknown[]}
|
|
29
|
+
onChange={(v) => onChange({ ...value, [root.name]: v })}
|
|
30
|
+
add={add}
|
|
31
|
+
/>
|
|
32
|
+
)}
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
package/src/DataForm.tsx
CHANGED
|
@@ -1,26 +1,19 @@
|
|
|
1
1
|
// A JSON file: one entry of a folder (a price, a testimonial, a category) or a single file (a fixed
|
|
2
2
|
// page, the menu). The files are written as JSON.stringify(data, null, 2) like the existing ones, with
|
|
3
3
|
// the keys in their order, so a save only changes what was edited. An earlier version (Versions.tsx)
|
|
4
|
-
// is opened as changes that are not saved.
|
|
4
|
+
// is opened as changes that are not saved. Exposes DataForm (default). Its values from the file and back: dataFileValues.ts;
|
|
5
|
+
// a menu's fields: DataFileFields.tsx.
|
|
5
6
|
import { useMemo, useState } from 'react';
|
|
6
7
|
import { Plus } from '@phosphor-icons/react';
|
|
7
|
-
import {
|
|
8
|
-
entryData,
|
|
9
|
-
entryName,
|
|
10
|
-
slugify,
|
|
11
|
-
slugOf,
|
|
12
|
-
type Collection,
|
|
13
|
-
type DataFile,
|
|
14
|
-
type Field,
|
|
15
|
-
type FilesCollection,
|
|
16
|
-
type FolderCollection,
|
|
17
|
-
} from './schema';
|
|
8
|
+
import { entryName, slugify, slugOf, type DataFile, type FilesCollection, type FolderCollection } from './schema';
|
|
18
9
|
import { config, header, keptBy, sharing, siteName, usesOf } from '@site/editor';
|
|
19
10
|
import { apps, asWritten, collections } from './site';
|
|
20
11
|
import { kindDraw } from './apps';
|
|
21
12
|
import { ConflictError, type RepoFile } from './github';
|
|
22
|
-
import { clean, newItem, newValue, problemAt
|
|
23
|
-
import { FieldInput, FieldList, Fields,
|
|
13
|
+
import { clean, newItem, newValue, problemAt } from './check';
|
|
14
|
+
import { FieldInput, FieldList, Fields, useWrongField, WrongField, type FieldContext } from './fields';
|
|
15
|
+
import { DataFileFields } from './DataFileFields';
|
|
16
|
+
import { dataOf, fieldsOf, listOnly, valuesOf } from './dataFileValues';
|
|
24
17
|
import SectionsField from './SectionsField';
|
|
25
18
|
import { notOffered, withPageBlocks } from './registry';
|
|
26
19
|
import { shownAt, useUploads } from './images';
|
|
@@ -43,16 +36,6 @@ type Props = {
|
|
|
43
36
|
kind?: string;
|
|
44
37
|
};
|
|
45
38
|
|
|
46
|
-
// A file that is only a list, without fields of its own beside it (a menu with "Aktiveret" is not).
|
|
47
|
-
const listOnly = (dataFile?: DataFile) => !!dataFile?.root && !dataFile.fields.length;
|
|
48
|
-
|
|
49
|
-
// A file's values in the form: its data (with the defaults of fields it has none for; an entry of a list as the list has
|
|
50
|
-
// it now, entryData), or a list under the field's name.
|
|
51
|
-
const valuesOf = (text: string, fields: Field[], dataFile: DataFile | undefined, collection: Collection, files: RepoFile[]) => {
|
|
52
|
-
if (listOnly(dataFile)) return { [dataFile!.root!.name]: JSON.parse(text) } as Record<string, unknown>;
|
|
53
|
-
return withDefaults(fields, collection.kind === 'folder' && !dataFile ? entryData(collection, text, files) : JSON.parse(text));
|
|
54
|
-
};
|
|
55
|
-
|
|
56
39
|
// The form starts again with an earlier version, or with the one saved now.
|
|
57
40
|
export default function DataForm(props: Props) {
|
|
58
41
|
return (
|
|
@@ -75,10 +58,7 @@ function Form({
|
|
|
75
58
|
// installed are not offered under "Indsæt" (`off`), but drawn and checked where they are, as on a page.
|
|
76
59
|
const off = useMemo(() => notOffered(env.files), [env.files]);
|
|
77
60
|
const fields = useMemo(
|
|
78
|
-
() =>
|
|
79
|
-
withPageBlocks(
|
|
80
|
-
dataFile ? (dataFile.root ? [...dataFile.fields, dataFile.root] : dataFile.fields) : (collection as FolderCollection).fields,
|
|
81
|
-
),
|
|
61
|
+
() => withPageBlocks(dataFile ? fieldsOf(dataFile) : (collection as FolderCollection).fields),
|
|
82
62
|
[dataFile, collection],
|
|
83
63
|
);
|
|
84
64
|
const [base, setBase] = useState(file);
|
|
@@ -135,10 +115,9 @@ function Form({
|
|
|
135
115
|
|
|
136
116
|
async function save() {
|
|
137
117
|
if (wrongField.stop(problemAt(fields, values))) return;
|
|
138
|
-
let data
|
|
118
|
+
let data = dataOf(fields, values, dataFile);
|
|
139
119
|
// Looked through now that it is saved (FolderCollection.fresh).
|
|
140
120
|
if (collection.kind === 'folder' && collection.fresh) delete (data as Record<string, unknown>)[collection.fresh.field];
|
|
141
|
-
if (listOnly(dataFile)) data = (data as Record<string, unknown>)[dataFile!.root!.name];
|
|
142
121
|
let path = base.path;
|
|
143
122
|
if (isNew) {
|
|
144
123
|
const folder = collection as FolderCollection;
|
|
@@ -310,10 +289,12 @@ function Form({
|
|
|
310
289
|
title={dataFile ? dataFile.label : isNew ? collection.create! : entryName(folder!, clean(fields, initial)) || kindLabel}
|
|
311
290
|
back={
|
|
312
291
|
header.includes(collection.id)
|
|
313
|
-
? { href: '#header', label: t.common.parts.header }
|
|
314
|
-
: collection.
|
|
315
|
-
? { href: '#', label:
|
|
316
|
-
:
|
|
292
|
+
? { href: '#udseende/header', label: t.common.parts.header }
|
|
293
|
+
: collection.id === 'footer'
|
|
294
|
+
? { href: '#udseende/footer', label: t.common.parts.footer }
|
|
295
|
+
: collection.kind === 'files' && collection.files.length === 1
|
|
296
|
+
? { href: '#', label: siteName }
|
|
297
|
+
: { href: `#liste/${collection.id}`, label: collection.label }
|
|
317
298
|
}
|
|
318
299
|
status={fixed ? savedStatus({ savedAt, saving, deleting }) : undefined}
|
|
319
300
|
view={view}
|
|
@@ -386,10 +367,7 @@ function Form({
|
|
|
386
367
|
/>
|
|
387
368
|
) : root ? (
|
|
388
369
|
<div className="space-y-6">
|
|
389
|
-
{dataFile
|
|
390
|
-
<FieldList fields={dataFile!.fields} value={values} onChange={(v) => setValues(v as Record<string, unknown>)} />
|
|
391
|
-
)}
|
|
392
|
-
<MenuList field={root} value={items} onChange={setItems} />
|
|
370
|
+
<DataFileFields dataFile={dataFile!} value={values} onChange={setValues} />
|
|
393
371
|
</div>
|
|
394
372
|
) : drawn ? (
|
|
395
373
|
// On a wide computer screen the drawing in the wide column and the fields beside it at the
|
package/src/Deleted.tsx
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// the site sends it on, which it says, and where to), a price or testimonial last in the list. A
|
|
6
6
|
// file from "Filer" is shown in a sheet first (FileView.tsx; a PDF, a video or a zip file with its icon
|
|
7
7
|
// and name over it), with "Gendan" under it, and comes back with its record of tags (fileRecords.mjs) when it
|
|
8
|
-
// was deleted together with it and no live record has its address.
|
|
8
|
+
// was deleted together with it and no live record has its address. "Gendan" shows the loading indicator while it restores.
|
|
9
9
|
import { useEffect, useRef, useState } from 'react';
|
|
10
10
|
import { ArrowCounterClockwise } from '@phosphor-icons/react';
|
|
11
11
|
import Screen from './Screen';
|
|
@@ -44,6 +44,8 @@ export default function Deleted({ id, files, env }: { id: string; files: RepoFil
|
|
|
44
44
|
const [deleted, setDeleted] = useState<DeletedFile[] | null>(null);
|
|
45
45
|
const [problem, setProblem] = useState('');
|
|
46
46
|
const [busy, setBusy] = useState(false);
|
|
47
|
+
// True only while a restore runs (not while `open` fetches the bytes); the sheet's button shows busy for it.
|
|
48
|
+
const [restoring, setRestoring] = useState(false);
|
|
47
49
|
// The file shown in the sheet, and its bytes as an address the <img> can show.
|
|
48
50
|
const dialog = useRef<HTMLDialogElement>(null);
|
|
49
51
|
const [shown, setShown] = useState<{ file: DeletedFile; src: string; bytes: Uint8Array } | null>(null);
|
|
@@ -86,6 +88,7 @@ export default function Deleted({ id, files, env }: { id: string; files: RepoFil
|
|
|
86
88
|
const name = nameOf(collection, file);
|
|
87
89
|
if (!pictures && !(await ask(t.history.restoreAsk(name), t.history.restore))) return;
|
|
88
90
|
setBusy(true);
|
|
91
|
+
setRestoring(true);
|
|
89
92
|
env.notify({ text: t.history.restoring });
|
|
90
93
|
try {
|
|
91
94
|
let content: string | Uint8Array = file.text;
|
|
@@ -132,6 +135,7 @@ export default function Deleted({ id, files, env }: { id: string; files: RepoFil
|
|
|
132
135
|
});
|
|
133
136
|
} finally {
|
|
134
137
|
setBusy(false);
|
|
138
|
+
setRestoring(false);
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
|
|
@@ -180,8 +184,8 @@ export default function Deleted({ id, files, env }: { id: string; files: RepoFil
|
|
|
180
184
|
)}
|
|
181
185
|
<FileView src={shown.src} name={fileName(shown.file.path)} />
|
|
182
186
|
<p className="text-sm text-muted">{capital(t.history.deletedAt(when(shown.file.at), shown.file.by))}</p>
|
|
183
|
-
<Button disabled={busy} onClick={() => restore(shown.file, shown.bytes)}>
|
|
184
|
-
{
|
|
187
|
+
<Button busy={restoring} disabled={busy} onClick={() => restore(shown.file, shown.bytes)}>
|
|
188
|
+
{t.history.restore}
|
|
185
189
|
</Button>
|
|
186
190
|
</SheetBody>
|
|
187
191
|
)}
|