@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.
Files changed (128) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/defaults/collections.ts +112 -58
  3. package/defaults/company.ts +1 -1
  4. package/defaults/editor.ts +2 -2
  5. package/defaults/texts.ts +14 -5
  6. package/demo/src/content/pages/app-store.md +0 -1
  7. package/demo/src/content/pages/blocks.md +0 -1
  8. package/icons/lucide/arrows-horizontal.svg +1 -0
  9. package/icons/lucide/columns-plus-left.svg +1 -0
  10. package/icons/lucide/columns-plus-right.svg +1 -0
  11. package/icons/lucide/file.svg +1 -0
  12. package/icons/material-symbols/arrows-horizontal.svg +1 -0
  13. package/icons/material-symbols/columns-plus-left.svg +1 -0
  14. package/icons/material-symbols/columns-plus-right.svg +1 -0
  15. package/icons/material-symbols/file.svg +1 -0
  16. package/icons/phosphor/arrows-horizontal.svg +1 -0
  17. package/icons/phosphor/columns-plus-left.svg +1 -0
  18. package/icons/phosphor/columns-plus-right.svg +1 -0
  19. package/icons/phosphor/file.svg +1 -0
  20. package/lib/fold-open.d.mts +2 -0
  21. package/lib/fold-open.mjs +22 -0
  22. package/lib/fold-open.test.mjs +24 -0
  23. package/lib/menu.test.mjs +16 -3
  24. package/lib/page-template.d.mts +54 -0
  25. package/lib/page-template.mjs +126 -0
  26. package/lib/page-template.test.mjs +129 -0
  27. package/lib/side-block.d.mts +2 -16
  28. package/lib/side-block.mjs +12 -30
  29. package/lib/side-block.test.mjs +13 -30
  30. package/package.json +1 -1
  31. package/scripts/check-side.mjs +49 -22
  32. package/scripts/editor-roundtrip.mjs +15 -6
  33. package/scripts/fixtures/side/side-full.md +8 -0
  34. package/scripts/fixtures/side/side-left.md +8 -0
  35. package/scripts/fixtures/side/side-none.md +7 -0
  36. package/scripts/fixtures/side/side-right.md +8 -0
  37. package/scripts/fixtures/side/side-single.md +8 -0
  38. package/scripts/side-width.mjs +29 -0
  39. package/scripts/side-width.test.mjs +30 -0
  40. package/site/content.config.ts +3 -4
  41. package/site/header.d.mts +21 -0
  42. package/site/header.mjs +35 -0
  43. package/site/header.test.mjs +59 -0
  44. package/site/icons.d.mts +16 -0
  45. package/site/icons.mjs +8 -0
  46. package/site/site.ts +9 -4
  47. package/site/templates.ts +7 -0
  48. package/site/theme.mjs +3 -0
  49. package/site/tracking.client.ts +4 -4
  50. package/src/App.tsx +12 -24
  51. package/src/AppList.tsx +36 -14
  52. package/src/AppSettingsTab.tsx +5 -3
  53. package/src/BlockDialog.tsx +50 -5
  54. package/src/DataFileFields.tsx +35 -0
  55. package/src/DataForm.tsx +16 -38
  56. package/src/Deleted.tsx +7 -3
  57. package/src/EntryForm.tsx +41 -88
  58. package/src/FooterSettings.tsx +76 -0
  59. package/src/FormFrame.tsx +2 -1
  60. package/src/HeaderSettings.tsx +100 -0
  61. package/src/Library.tsx +26 -14
  62. package/src/MarkdownEditor.tsx +18 -36
  63. package/src/Media.tsx +6 -3
  64. package/src/Redirects.tsx +12 -9
  65. package/src/SectionSettings.tsx +21 -0
  66. package/src/SectionSettingsIcon.tsx +10 -0
  67. package/src/SettingsSections.tsx +57 -0
  68. package/src/ThemeStore.tsx +28 -9
  69. package/src/Versions.tsx +3 -15
  70. package/src/busyAction.d.mts +3 -0
  71. package/src/busyAction.mjs +8 -0
  72. package/src/busyAction.test.mjs +16 -0
  73. package/src/dataFileValues.ts +30 -0
  74. package/src/editor.css +42 -3
  75. package/src/fields.tsx +29 -10
  76. package/src/footerPreview.d.mts +46 -0
  77. package/src/footerPreview.mjs +84 -0
  78. package/src/footerPreview.test.mjs +100 -0
  79. package/src/formSections.ts +100 -0
  80. package/src/frontmatter.mjs +1 -1
  81. package/src/frontmatter.test.mjs +11 -11
  82. package/src/headerPreview.d.mts +48 -0
  83. package/src/headerPreview.mjs +168 -0
  84. package/src/headerPreview.test.mjs +183 -0
  85. package/src/headerTheme.tsx +58 -0
  86. package/src/i18n/da-DK/common.ts +1 -0
  87. package/src/i18n/da-DK/library.ts +2 -0
  88. package/src/i18n/da-DK/preview.ts +2 -0
  89. package/src/i18n/da-DK/text.ts +1 -0
  90. package/src/i18n/en-US/common.ts +1 -0
  91. package/src/i18n/en-US/library.ts +4 -0
  92. package/src/i18n/en-US/preview.ts +3 -0
  93. package/src/i18n/en-US/text.ts +2 -1
  94. package/src/images.ts +7 -2
  95. package/src/menuChildren.ts +1 -3
  96. package/src/movedRoutes.d.mts +3 -0
  97. package/src/movedRoutes.mjs +35 -0
  98. package/src/movedRoutes.test.mjs +22 -0
  99. package/src/pagePreview.tsx +40 -29
  100. package/src/partPreview.tsx +59 -0
  101. package/src/schema.ts +7 -1
  102. package/src/sheetPlacement.d.mts +3 -0
  103. package/src/sheetPlacement.mjs +9 -0
  104. package/src/sheetPlacement.test.mjs +23 -0
  105. package/src/standard/views.ts +3 -3
  106. package/src/templateField.ts +28 -0
  107. package/src/themes.ts +3 -0
  108. package/src/ui.tsx +35 -8
  109. package/src/useDataFiles.ts +94 -0
  110. package/starter/package.json +1 -1
  111. package/theme/components/Footer.astro +14 -7
  112. package/theme/components/Header.astro +58 -33
  113. package/theme/components/MobileNav.astro +5 -4
  114. package/theme/components/RelatedPosts.astro +1 -1
  115. package/theme/components/headerParts.ts +17 -0
  116. package/theme/layouts/PageLayout.astro +20 -16
  117. package/theme/styles/base.css +6 -5
  118. package/theme/styles/blocks.css +8 -12
  119. package/theme/styles/components.css +8 -1
  120. package/theme/styles/global.css +5 -9
  121. package/theme/styles/ui.css +111 -5
  122. package/theme/theme.json +1 -0
  123. package/theme/ui/BusyContent.tsx +16 -0
  124. package/theme/ui/Button.tsx +25 -5
  125. package/theme/ui/IconButton.tsx +17 -4
  126. package/scripts/fixtures/side/side-off.md +0 -8
  127. package/scripts/fixtures/side/side-plain.md +0 -7
  128. 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
- const [saving, setSaving] = useState(false);
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
- setSaving(true);
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
- setSaving(false);
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(false);
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(true);
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(false);
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 app={app} env={env} files={files} busy={saving} stuck={stuck} onUninstall={() => install(false)} />
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
@@ -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>
@@ -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={block ? block.label : t.blocks.insert}
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
- onClick={(e) => e.target === dialog.current && void leave()}
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={block ? block.label : t.blocks.insert} onClose={() => void leave()} />
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, withDefaults } from './check';
23
- import { FieldInput, FieldList, Fields, MenuList, useWrongField, WrongField, type FieldContext } from './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: unknown = clean(fields, values);
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.kind === 'files' && collection.files.length === 1
315
- ? { href: '#', label: siteName }
316
- : { href: `#liste/${collection.id}`, label: collection.label }
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!.fields.length > 0 && (
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
- {busy ? t.history.restoring : t.history.restore}
187
+ <Button busy={restoring} disabled={busy} onClick={() => restore(shown.file, shown.bytes)}>
188
+ {t.history.restore}
185
189
  </Button>
186
190
  </SheetBody>
187
191
  )}