@morphemecms/cms 0.9.0-next-20261105122349 → 0.9.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.
Files changed (126) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/defaults/collections.ts +110 -56
  3. package/defaults/editor.ts +2 -2
  4. package/defaults/texts.ts +14 -5
  5. package/demo/src/content/pages/app-store.md +0 -1
  6. package/demo/src/content/pages/blocks.md +0 -1
  7. package/icons/lucide/arrows-horizontal.svg +1 -0
  8. package/icons/lucide/columns-plus-left.svg +1 -0
  9. package/icons/lucide/columns-plus-right.svg +1 -0
  10. package/icons/lucide/file.svg +1 -0
  11. package/icons/material-symbols/arrows-horizontal.svg +1 -0
  12. package/icons/material-symbols/columns-plus-left.svg +1 -0
  13. package/icons/material-symbols/columns-plus-right.svg +1 -0
  14. package/icons/material-symbols/file.svg +1 -0
  15. package/icons/phosphor/arrows-horizontal.svg +1 -0
  16. package/icons/phosphor/columns-plus-left.svg +1 -0
  17. package/icons/phosphor/columns-plus-right.svg +1 -0
  18. package/icons/phosphor/file.svg +1 -0
  19. package/lib/fold-open.d.mts +2 -0
  20. package/lib/fold-open.mjs +22 -0
  21. package/lib/fold-open.test.mjs +24 -0
  22. package/lib/menu.test.mjs +16 -3
  23. package/lib/page-template.d.mts +54 -0
  24. package/lib/page-template.mjs +126 -0
  25. package/lib/page-template.test.mjs +129 -0
  26. package/lib/side-block.d.mts +2 -16
  27. package/lib/side-block.mjs +12 -30
  28. package/lib/side-block.test.mjs +13 -30
  29. package/package.json +1 -1
  30. package/scripts/check-side.mjs +49 -22
  31. package/scripts/editor-roundtrip.mjs +15 -6
  32. package/scripts/fixtures/side/side-full.md +8 -0
  33. package/scripts/fixtures/side/side-left.md +8 -0
  34. package/scripts/fixtures/side/side-none.md +7 -0
  35. package/scripts/fixtures/side/side-right.md +8 -0
  36. package/scripts/fixtures/side/side-single.md +8 -0
  37. package/scripts/side-width.mjs +29 -0
  38. package/scripts/side-width.test.mjs +30 -0
  39. package/site/content.config.ts +3 -4
  40. package/site/header.d.mts +21 -0
  41. package/site/header.mjs +35 -0
  42. package/site/header.test.mjs +59 -0
  43. package/site/icons.d.mts +16 -0
  44. package/site/icons.mjs +8 -0
  45. package/site/site.ts +8 -3
  46. package/site/templates.ts +7 -0
  47. package/site/theme.mjs +3 -0
  48. package/src/App.tsx +12 -24
  49. package/src/AppList.tsx +36 -14
  50. package/src/AppSettingsTab.tsx +5 -3
  51. package/src/BlockDialog.tsx +50 -5
  52. package/src/DataFileFields.tsx +35 -0
  53. package/src/DataForm.tsx +16 -38
  54. package/src/Deleted.tsx +7 -3
  55. package/src/EntryForm.tsx +41 -88
  56. package/src/FooterSettings.tsx +76 -0
  57. package/src/FormFrame.tsx +2 -1
  58. package/src/HeaderSettings.tsx +97 -0
  59. package/src/Library.tsx +26 -14
  60. package/src/MarkdownEditor.tsx +18 -36
  61. package/src/Media.tsx +6 -3
  62. package/src/Redirects.tsx +12 -9
  63. package/src/SectionSettings.tsx +21 -0
  64. package/src/SectionSettingsIcon.tsx +10 -0
  65. package/src/SettingsSections.tsx +57 -0
  66. package/src/ThemeStore.tsx +28 -9
  67. package/src/Versions.tsx +3 -15
  68. package/src/busyAction.d.mts +3 -0
  69. package/src/busyAction.mjs +8 -0
  70. package/src/busyAction.test.mjs +16 -0
  71. package/src/dataFileValues.ts +30 -0
  72. package/src/editor.css +37 -3
  73. package/src/fields.tsx +29 -10
  74. package/src/footerPreview.d.mts +46 -0
  75. package/src/footerPreview.mjs +84 -0
  76. package/src/footerPreview.test.mjs +100 -0
  77. package/src/formSections.ts +100 -0
  78. package/src/frontmatter.mjs +1 -1
  79. package/src/frontmatter.test.mjs +11 -11
  80. package/src/headerPreview.d.mts +48 -0
  81. package/src/headerPreview.mjs +168 -0
  82. package/src/headerPreview.test.mjs +183 -0
  83. package/src/headerTheme.tsx +58 -0
  84. package/src/i18n/da-DK/common.ts +1 -0
  85. package/src/i18n/da-DK/library.ts +2 -0
  86. package/src/i18n/da-DK/preview.ts +2 -0
  87. package/src/i18n/da-DK/text.ts +1 -0
  88. package/src/i18n/en-US/common.ts +1 -0
  89. package/src/i18n/en-US/library.ts +4 -0
  90. package/src/i18n/en-US/preview.ts +3 -0
  91. package/src/i18n/en-US/text.ts +2 -1
  92. package/src/images.ts +7 -2
  93. package/src/menuChildren.ts +1 -3
  94. package/src/movedRoutes.d.mts +3 -0
  95. package/src/movedRoutes.mjs +35 -0
  96. package/src/movedRoutes.test.mjs +22 -0
  97. package/src/pagePreview.tsx +40 -29
  98. package/src/partPreview.tsx +59 -0
  99. package/src/schema.ts +7 -1
  100. package/src/sheetPlacement.d.mts +3 -0
  101. package/src/sheetPlacement.mjs +9 -0
  102. package/src/sheetPlacement.test.mjs +23 -0
  103. package/src/standard/views.ts +3 -3
  104. package/src/templateField.ts +28 -0
  105. package/src/themes.ts +3 -0
  106. package/src/ui.tsx +35 -8
  107. package/src/useDataFiles.ts +94 -0
  108. package/starter/package.json +1 -1
  109. package/theme/components/Footer.astro +14 -7
  110. package/theme/components/Header.astro +58 -33
  111. package/theme/components/MobileNav.astro +5 -4
  112. package/theme/components/RelatedPosts.astro +1 -1
  113. package/theme/components/headerParts.ts +17 -0
  114. package/theme/layouts/PageLayout.astro +20 -16
  115. package/theme/styles/base.css +6 -5
  116. package/theme/styles/blocks.css +8 -12
  117. package/theme/styles/components.css +8 -1
  118. package/theme/styles/global.css +5 -9
  119. package/theme/styles/ui.css +111 -5
  120. package/theme/theme.json +1 -0
  121. package/theme/ui/BusyContent.tsx +16 -0
  122. package/theme/ui/Button.tsx +25 -5
  123. package/theme/ui/IconButton.tsx +17 -4
  124. package/scripts/fixtures/side/side-off.md +0 -8
  125. package/scripts/fixtures/side/side-plain.md +0 -7
  126. package/scripts/fixtures/side/side-wide.md +0 -8
@@ -0,0 +1,35 @@
1
+ // The header's site setting (src/data/header.json, optional): a button in place of the phone icon, and the side of the
2
+ // header the mobile menu button sits on; and the main menu's items as the header draws them. Exposes headerOf (pure;
3
+ // missing or wrong data gives the defaults) and sortedNav. Used by site/site.ts and the editor's preview of the header
4
+ // (src/headerPreview.mjs); the Button type is src/standard/buttons/fields.ts.
5
+
6
+ /** @typedef {import('../src/standard/buttons/fields').Button} Button */
7
+ /** @typedef {{ button: Button | null, menuSide: 'start' | 'end' }} Header */
8
+
9
+ /**
10
+ * Reads src/data/header.json's content. Missing or wrong → defaults.
11
+ * @param {unknown} raw
12
+ * @returns {Header}
13
+ */
14
+ export function headerOf(raw) {
15
+ const data = raw && typeof raw === 'object' ? /** @type {Record<string, any>} */ (raw) : {};
16
+ const { enabled, ...button } = data.button && typeof data.button === 'object' ? data.button : { enabled: false };
17
+ return {
18
+ button: enabled === true ? button : null,
19
+ menuSide: data.menuSide === 'end' ? 'end' : 'start',
20
+ };
21
+ }
22
+
23
+ /** @typedef {{ label: string, href: string }} NavLink */
24
+ /** @typedef {NavLink & { children?: NavLink[] }} NavItem */
25
+
26
+ /**
27
+ * The main menu's items (menu.json) as the header draws them: the sub-items in alphabetical order (Danish: æ, ø, å last).
28
+ * @template {NavItem} T
29
+ * @param {T[]} items
30
+ * @returns {T[]}
31
+ */
32
+ export const sortedNav = (items) =>
33
+ items.map((item) =>
34
+ item.children ? { ...item, children: [...item.children].sort((a, b) => a.label.localeCompare(b.label, 'da')) } : item,
35
+ );
@@ -0,0 +1,59 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test } from 'node:test';
3
+ import { headerOf, sortedNav } from './header.mjs';
4
+
5
+ test('no file', () => {
6
+ assert.deepEqual(headerOf(undefined), { button: null, menuSide: 'start' });
7
+ });
8
+
9
+ test('button off', () => {
10
+ assert.equal(headerOf({ button: { enabled: false, to: 'booking' } }).button, null);
11
+ });
12
+
13
+ test('button on', () => {
14
+ assert.deepEqual(headerOf({ button: { enabled: true, to: 'booking', label: 'Book en tid' } }).button, {
15
+ to: 'booking',
16
+ label: 'Book en tid',
17
+ });
18
+ });
19
+
20
+ test('menu at the end', () => {
21
+ assert.equal(headerOf({ menuSide: 'end' }).menuSide, 'end');
22
+ });
23
+
24
+ test('wrong menu side', () => {
25
+ assert.equal(headerOf({ menuSide: 'middle' }).menuSide, 'start');
26
+ });
27
+
28
+ test('wrong shapes', () => {
29
+ assert.deepEqual(headerOf('x'), { button: null, menuSide: 'start' });
30
+ assert.equal(headerOf({ button: 'x' }).button, null);
31
+ });
32
+
33
+ test('sortedNav sorts the sub-items A–Å and keeps the items in order', () => {
34
+ const items = [
35
+ { label: 'Om', href: '/om/' },
36
+ {
37
+ label: 'Behandlinger',
38
+ href: '/b/',
39
+ children: [
40
+ { label: 'Øre', href: '/b/oe/' },
41
+ { label: 'Arm', href: '/b/a/' },
42
+ { label: 'Zone', href: '/b/z/' },
43
+ ],
44
+ },
45
+ ];
46
+ const sorted = sortedNav(items);
47
+ assert.deepEqual(
48
+ sorted.map((i) => i.label),
49
+ ['Om', 'Behandlinger'],
50
+ );
51
+ assert.deepEqual(
52
+ sorted[1].children.map((c) => c.label),
53
+ ['Arm', 'Zone', 'Øre'],
54
+ );
55
+ assert.deepEqual(
56
+ items[1].children.map((c) => c.label),
57
+ ['Øre', 'Arm', 'Zone'],
58
+ );
59
+ });
package/site/icons.d.mts CHANGED
@@ -107,6 +107,7 @@ export const MATERIAL_SYMBOLS: {
107
107
  'arrow-up': string;
108
108
  'arrow-up-right': string;
109
109
  'arrows-down-up': string;
110
+ 'arrows-horizontal': string;
110
111
  article: string;
111
112
  bank: string;
112
113
  bell: string;
@@ -142,6 +143,8 @@ export const MATERIAL_SYMBOLS: {
142
143
  code: string;
143
144
  coffee: string;
144
145
  columns: string;
146
+ 'columns-plus-left': string;
147
+ 'columns-plus-right': string;
145
148
  cookie: string;
146
149
  copy: string;
147
150
  'credit-card': string;
@@ -155,6 +158,7 @@ export const MATERIAL_SYMBOLS: {
155
158
  'envelope-simple-fill': string;
156
159
  eye: string;
157
160
  'eye-slash': string;
161
+ file: string;
158
162
  'file-pdf': string;
159
163
  'file-text': string;
160
164
  'file-text-fill': string;
@@ -290,6 +294,7 @@ export const LUCIDE: {
290
294
  'arrow-up': string;
291
295
  'arrow-up-right': string;
292
296
  'arrows-down-up': string;
297
+ 'arrows-horizontal': string;
293
298
  'arrows-in-line-horizontal': string;
294
299
  'arrows-out-line-horizontal': string;
295
300
  article: string;
@@ -328,6 +333,8 @@ export const LUCIDE: {
328
333
  code: string;
329
334
  coffee: string;
330
335
  columns: string;
336
+ 'columns-plus-left': string;
337
+ 'columns-plus-right': string;
331
338
  cookie: string;
332
339
  copy: string;
333
340
  'credit-card': string;
@@ -341,6 +348,7 @@ export const LUCIDE: {
341
348
  'envelope-simple-fill': string;
342
349
  eye: string;
343
350
  'eye-slash': string;
351
+ file: string;
344
352
  'file-pdf': string;
345
353
  'file-text': string;
346
354
  'file-text-fill': string;
@@ -479,6 +487,7 @@ export const PACK_MAPS: {
479
487
  'arrow-up': string;
480
488
  'arrow-up-right': string;
481
489
  'arrows-down-up': string;
490
+ 'arrows-horizontal': string;
482
491
  article: string;
483
492
  bank: string;
484
493
  bell: string;
@@ -514,6 +523,8 @@ export const PACK_MAPS: {
514
523
  code: string;
515
524
  coffee: string;
516
525
  columns: string;
526
+ 'columns-plus-left': string;
527
+ 'columns-plus-right': string;
517
528
  cookie: string;
518
529
  copy: string;
519
530
  'credit-card': string;
@@ -527,6 +538,7 @@ export const PACK_MAPS: {
527
538
  'envelope-simple-fill': string;
528
539
  eye: string;
529
540
  'eye-slash': string;
541
+ file: string;
530
542
  'file-pdf': string;
531
543
  'file-text': string;
532
544
  'file-text-fill': string;
@@ -660,6 +672,7 @@ export const PACK_MAPS: {
660
672
  'arrow-up': string;
661
673
  'arrow-up-right': string;
662
674
  'arrows-down-up': string;
675
+ 'arrows-horizontal': string;
663
676
  'arrows-in-line-horizontal': string;
664
677
  'arrows-out-line-horizontal': string;
665
678
  article: string;
@@ -698,6 +711,8 @@ export const PACK_MAPS: {
698
711
  code: string;
699
712
  coffee: string;
700
713
  columns: string;
714
+ 'columns-plus-left': string;
715
+ 'columns-plus-right': string;
701
716
  cookie: string;
702
717
  copy: string;
703
718
  'credit-card': string;
@@ -711,6 +726,7 @@ export const PACK_MAPS: {
711
726
  'envelope-simple-fill': string;
712
727
  eye: string;
713
728
  'eye-slash': string;
729
+ file: string;
714
730
  'file-pdf': string;
715
731
  'file-text': string;
716
732
  'file-text-fill': string;
package/site/icons.mjs CHANGED
@@ -121,6 +121,7 @@ export const MATERIAL_SYMBOLS = {
121
121
  'arrow-up': 'arrow_upward',
122
122
  'arrow-up-right': 'arrow_outward',
123
123
  'arrows-down-up': 'swap_vert',
124
+ 'arrows-horizontal': 'width_full',
124
125
  article: 'article',
125
126
  bank: 'account_balance',
126
127
  bell: 'notifications',
@@ -156,6 +157,8 @@ export const MATERIAL_SYMBOLS = {
156
157
  code: 'code',
157
158
  coffee: 'local_cafe',
158
159
  columns: 'view_column_2',
160
+ 'columns-plus-left': 'dock_to_right',
161
+ 'columns-plus-right': 'dock_to_left',
159
162
  cookie: 'cookie',
160
163
  copy: 'content_copy',
161
164
  'credit-card': 'credit_card',
@@ -169,6 +172,7 @@ export const MATERIAL_SYMBOLS = {
169
172
  'envelope-simple-fill': 'mail-fill',
170
173
  eye: 'visibility',
171
174
  'eye-slash': 'visibility_off',
175
+ file: 'crop_portrait',
172
176
  'file-pdf': 'picture_as_pdf',
173
177
  'file-text': 'description',
174
178
  'file-text-fill': 'description-fill',
@@ -305,6 +309,7 @@ export const LUCIDE = {
305
309
  'arrow-up': 'arrow-up',
306
310
  'arrow-up-right': 'arrow-up-right',
307
311
  'arrows-down-up': 'arrow-down-up',
312
+ 'arrows-horizontal': 'rectangle-horizontal',
308
313
  'arrows-in-line-horizontal': 'fold-horizontal',
309
314
  'arrows-out-line-horizontal': 'unfold-horizontal',
310
315
  article: 'file-text',
@@ -343,6 +348,8 @@ export const LUCIDE = {
343
348
  code: 'code',
344
349
  coffee: 'coffee',
345
350
  columns: 'columns-2',
351
+ 'columns-plus-left': 'panel-left',
352
+ 'columns-plus-right': 'panel-right',
346
353
  cookie: 'cookie',
347
354
  copy: 'copy',
348
355
  'credit-card': 'credit-card',
@@ -356,6 +363,7 @@ export const LUCIDE = {
356
363
  'envelope-simple-fill': 'mail-fill',
357
364
  eye: 'eye',
358
365
  'eye-slash': 'eye-off',
366
+ file: 'rectangle-vertical',
359
367
  'file-pdf': 'file-text',
360
368
  'file-text': 'file-text',
361
369
  'file-text-fill': 'file-text-fill',
package/site/site.ts CHANGED
@@ -12,6 +12,7 @@ import blocks from '@data/blocks.json';
12
12
  import standardPages from '@data/standard-pages.json';
13
13
  import { config } from '@site/config';
14
14
  import { socialOf } from './social.mjs';
15
+ import { headerOf, sortedNav } from './header.mjs';
15
16
  import { sharedTexts, type SharedTexts } from '@morphemecms/cms/src/shared';
16
17
  import { standardShared } from '@morphemecms/cms/src/standard/sharedTexts';
17
18
 
@@ -24,6 +25,11 @@ const { cvr = '' } = contact as typeof contact & { cvr?: string };
24
25
  // Optional file: an older site has no social.json, so it is read with a glob, not an import.
25
26
  const socialFile = Object.values(import.meta.glob<Record<string, unknown>>('/src/data/social.json', { eager: true, import: 'default' }))[0];
26
27
 
28
+ // Optional file (header.json, "Header"): a site without it keeps the header as it is.
29
+ const headerFile = Object.values(import.meta.glob<Record<string, unknown>>('/src/data/header.json', { eager: true, import: 'default' }))[0];
30
+ /** The header's button (in place of the phone icon, or null) and the side of the mobile menu button. */
31
+ export const header = headerOf(headerFile);
32
+
27
33
  export const site = {
28
34
  name: contact.name,
29
35
  role: contact.role,
@@ -65,13 +71,12 @@ export const site = {
65
71
 
66
72
  // Primary navigation (menu.json, "Hovedmenu"). Labels, URLs and sub-items started out as the old site's menu.
67
73
  export type NavItem = { label: string; href: string; children?: { label: string; href: string }[] };
68
- export const nav: NavItem[] = menu.items;
74
+ // Sub-items in alphabetical order (Danish: æ, ø, å last), in both the desktop and the mobile menu.
75
+ export const nav: NavItem[] = sortedNav(menu.items);
69
76
  /** Whether the header has the menu and its button ("Aktiveret" under "Hovedmenu"). */
70
77
  export const navShown = menu.enabled !== false;
71
78
  /** The links in the bar under the header on a phone (mobile-menu.json, "Mobilmenu"); none when it is turned off. */
72
79
  export const mobileNav: { label: string; href: string }[] = mobileMenu.enabled ? mobileMenu.items : [];
73
- // Sub-items in alphabetical order (Danish: æ, ø, å last), in both the desktop and the mobile menu.
74
- for (const item of nav) item.children?.sort((a, b) => a.label.localeCompare(b.label, 'da'));
75
80
 
76
81
  /** The texts every copy of a block has ("Blokke" in the editor), with {…} for what differs, by
77
82
  * the block's type: those written in its shared.ts, with the saved ones (blocks.json) over them. The
@@ -0,0 +1,7 @@
1
+ // The page templates the site's theme offers: the theme in use's ("templates" in its theme.json), else the standard theme's.
2
+ // Exposes offeredTemplates, read by PageLayout.astro and the check in scripts/check-side.mjs.
3
+ import { templatesOf } from '../lib/page-template.mjs';
4
+ import theme from '@theme/theme.json';
5
+
6
+ /** The templates a page can choose, the first the default. */
7
+ export const offeredTemplates: string[] = templatesOf(theme as { templates?: unknown });
package/site/theme.mjs CHANGED
@@ -14,6 +14,7 @@ import { installedPacks, isIconName, packFolder, packProblems, phosphorFile, svg
14
14
  import { MATERIAL_ALL, MATERIAL_SSR, materialModule, materialSsrModule } from './materialSymbols.mjs';
15
15
  import { ICON_PACK_FILE, ICON_PACK_LICENSE, SYSTEM_PACKS, THEME_PACKS_FOLDER, packInUse, packTheme, themePackId } from './iconPacks.mjs';
16
16
  import { MOTION_SCHEMES } from '@morphemecms/cms/lib/motion-schemes.mjs';
17
+ import { templateProblems } from '@morphemecms/cms/lib/page-template.mjs';
17
18
  import { storeImportProblems } from './storeTheme.mjs';
18
19
 
19
20
  /** The site's choice of icon pack, `{ "pack": "<id>" }` (config.icons in src/site/config.ts). */
@@ -400,6 +401,8 @@ export function themeProblems(theme) {
400
401
  problems.push(
401
402
  `${themeFolder(theme)}/theme.json: "motion" er den bevægelse, temaet bruger, når ingen er valgt: ${MOTION_SCHEMES.map((s) => `"${s}"`).join(', ')}.`,
402
403
  );
404
+ // The page templates it offers (lib/page-template.mjs); without the key, the standard theme's list.
405
+ for (const p of templateProblems(manifest.templates)) problems.push(`${themeFolder(theme)}/theme.json: ${p}`);
403
406
  // Its own licence and the licences of what it brings (fonts, code, pictures), for "Licenser" and the store.
404
407
  problems.push(
405
408
  ...declarationProblems({
package/src/App.tsx CHANGED
@@ -3,7 +3,6 @@
3
3
  // (empty) the start page
4
4
  // liste/<id> the list of a part, e.g. liste/priser, and liste/<id>/<app> a tab an installed app adds to it
5
5
  // (AppDefinition.tabs): liste/cookies/cookiescanner
6
- // header what is in the header on every page (the menu)
7
6
  // lister the short lists (the testimonials, the apps' lists)
8
7
  // indstillinger/appstore the app store, its apps, and indstillinger/appstore/temaer its themes (AppList.tsx StorePage); the
9
8
  // other settings are screens of their own, in the drawer's last section (MainMenu.tsx)
@@ -22,6 +21,8 @@
22
21
  // udseende how the site looks (Library.tsx Theme): udseende/temaer the themes installed (Themes) and udseende/temaer/<theme>
23
22
  // a theme's page, with the button that swaps to it (ThemePage), udseende/ikonpakker the icon packs (IconPacks),
24
23
  // udseende/bevaegelse how it moves (MotionSchemes), udseende/sidekolonne the block beside the pages' text (SideColumn),
24
+ // udseende/header the header's menus and settings with its preview (HeaderSettings.tsx; once at header),
25
+ // udseende/footer the footer's texts and links with its preview (FooterSettings.tsx; once at src/data/footer.json),
25
26
  // udseende/farver the colours (Colors.tsx;
26
27
  // udseende/farver/reservefarver the site's own while an app changes the pages'), udseende/logo the logo
27
28
  // and icon (BrandForm.tsx), udseende/dele the parts (Library.tsx PartsHome): udseende/dele/editor the editor's, every
@@ -132,12 +133,13 @@ import {
132
133
  import Colors, { colorsOf, FALLBACK_ROUTE } from './Colors';
133
134
  import { applyColors, applyMotion } from './theme';
134
135
  import SideColumn from './SideColumn';
136
+ import HeaderSettings from './HeaderSettings';
137
+ import FooterSettings from './FooterSettings';
135
138
  import { apps, notOffered } from './registry';
136
139
  import {
137
140
  Account,
138
141
  Blocks,
139
142
  Company,
140
- Header,
141
143
  List,
142
144
  Lists,
143
145
  PartsHome,
@@ -168,6 +170,7 @@ import { arrived, screenOf, up } from './nav';
168
170
  import { siteT, siteText, t } from './i18n';
169
171
  import { snackbar } from '../lib/snackbar';
170
172
  import { appRouteOf } from '../lib/appRoute.mjs';
173
+ import { movedRoute } from './movedRoutes.mjs';
171
174
  import { appType, clearNotifications, isOn, remember, signOff, stopServerWait, syncDevice, told, waitOnServer } from './notificationData';
172
175
  import Notifications from './Notifications';
173
176
  import { EditorOwn, InEditor } from './standard/view';
@@ -262,32 +265,15 @@ function taken(address: string, relations: Record<Relation, Option[]>, redirects
262
265
  return { text: t.app.addressRedirects(address, name), redirect: { end, name, via } };
263
266
  }
264
267
 
265
- // Screens that have moved, by their old address: a link or a bookmark to one opens it where it is now.
266
- const MOVED: Record<string, string> = {
267
- farver: 'udseende/farver',
268
- 'virksomheden/logo': 'udseende/logo',
269
- 'tema/blokvalg': 'blokvalg',
270
- dele: 'udseende/dele',
271
- tema: 'udseende',
272
- 'tema/farver': 'udseende/farver',
273
- 'tema/logo': 'udseende/logo',
274
- 'tema/dele': 'udseende/dele',
275
- 'tema/dele/editor': 'udseende/dele/editor',
276
- 'virksomheden/bank': 'virksomheden/betaling/bank',
277
- videresendelser: 'seo/videresendelser',
278
- 'videresendelser/alle': 'seo/videresendelser/alle',
279
- 'videresendelser/ikke-fundet': 'seo/videresendelser/ikke-fundet',
280
- // The settings of the booking and newsletter apps were on a screen "Promovering"; they are on the apps' own pages now.
281
- promovering: 'apps',
282
- 'promovering/calendly': 'apps/calendly',
283
- 'promovering/mailchimp': 'apps/mailchimp',
268
+ // The app store's screens that have moved (movedRoutes.mjs has the others).
269
+ const STORE_MOVED: Record<string, string> = {
284
270
  [STORE_BEFORE]: STORE_ROUTE,
285
271
  [`${STORE_BEFORE}/temaer`]: STORE_THEMES,
286
272
  };
287
273
 
288
274
  // The editor's address after the "#" (nav.ts screenOf).
289
275
  function routeOf(hash: string) {
290
- const route = MOVED[screenOf(hash)] ?? screenOf(hash);
276
+ const route = movedRoute(screenOf(hash), STORE_MOVED);
291
277
  // An app's settings: on the screen with its category, when it has one ("Analytics", AppList.tsx settingsRoute), and so
292
278
  // the pages of their fields (Field.page) after them. Otherwise they are on the Settings tab of its page: apps/<id>
293
279
  // (a field's page, apps/<id>/indstillinger/<field>, stays, drawn by the same page).
@@ -734,7 +720,6 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
734
720
  if (!route) return <Start builtAt={builtAt} />;
735
721
  if (route === 'konto') return <Account user={user} files={files} onLogout={onLogout} />;
736
722
  if (route === 'notifikationer') return <Notifications />;
737
- if (route === 'header') return <Header files={files} />;
738
723
  if (route === 'lister') return <Lists files={files} />;
739
724
  if (route === 'virksomheden') return <Company files={files} />;
740
725
  if (route === 'blokke') return <Blocks files={files} />;
@@ -755,6 +740,8 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
755
740
  if (route === 'udseende/ikonpakker') return <IconPacks env={env} files={files} />;
756
741
  if (route === 'udseende/bevaegelse') return <MotionSchemes env={env} files={files} />;
757
742
  if (route === 'udseende/sidekolonne') return <SideColumn env={env} files={files} />;
743
+ if (route === 'udseende/header') return <HeaderSettings env={env} />;
744
+ if (route === 'udseende/footer') return <FooterSettings env={env} />;
758
745
  // A theme installed has its page; one from the store that is not (or not yet built), its page in the store.
759
746
  const themeShown = route.startsWith('udseende/temaer/') && themes.find((x) => x.id === route.slice(16));
760
747
  if (themeShown) return <ThemePage key={themeShown.id} of={themeShown} env={env} files={files} />;
@@ -819,7 +806,8 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
819
806
  }
820
807
 
821
808
  const found = collectionOf(collections, route);
822
- const file = files.find((f) => f.path === route);
809
+ // An optional file (the header's) that the site has not got yet opens empty; the first save makes it.
810
+ const file = files.find((f) => f.path === route) ?? (found?.dataFile?.optional ? { path: route, oid: '', text: '{}\n' } : undefined);
823
811
  if (!found || !file)
824
812
  return (
825
813
  <Screen title={t.app.notFound} back={{ href: '#', label: siteName }}>
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>