@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/EntryForm.tsx CHANGED
@@ -5,9 +5,9 @@
5
5
  // old one (addresses.ts). The Filer files the page newly refers to get the page's tags in the same commit (autoTags.mjs).
6
6
  // An earlier version (Versions.tsx) is opened as changes that are not saved,
7
7
  // at the address the page or post has now. Its preview draft says whether it is a post (post, for the lead).
8
- // The form's sections are listed as "on this page" in a card at the end of the form (large and wider only): SectionList in ui.tsx.
8
+ // The form's sections are listed as "on this page" in a card at the end of the form (large and wider only): SectionList in ui.tsx;
9
+ // which are open and the one in view: formSections.ts.
9
10
  import { useEffect, useMemo, useRef, useState } from 'react';
10
- import { flushSync } from 'react-dom';
11
11
  import { slugify, slugOf, type Field, type MarkdownCollection } from './schema';
12
12
  import { config, frontLeads, headingLooks, sectionLooks, sharing, standardPages, standardPaths } from '@site/editor';
13
13
  import { asWritten, placeOf, standardPageAt } from './site';
@@ -16,15 +16,15 @@ import { addressOf, moveRedirects, REDIRECTS, retarget } from './addresses';
16
16
  import { changesOf, flatten, readAll, REDIRECT_FILES, setRedirect } from './redirectData';
17
17
  import { TAB_ROUTES } from './Redirects';
18
18
  import { choose } from './ask';
19
- import { sideSettingOf } from './SideColumn';
20
- import { sideSwitchShown } from '../lib/side-block.mjs';
19
+ import { offeredTemplatesOf, shownTemplate, templateFieldOf } from './templateField';
20
+ import { templateOf, templateWritten } from '../lib/page-template.mjs';
21
21
  import { booleanRead, booleanWrite, readFields, splitFrontmatter, writeFields } from './frontmatter.mjs';
22
22
  import MarkdownEditor, { type MarkdownHandle } from './MarkdownEditor';
23
23
  import { useBlockDialog } from './BlockDialog';
24
24
  import { blockHtml } from '../lib/blocks.mjs';
25
25
  import { sectionBlocks } from './blocks';
26
26
  import { looksOf, pageH1 } from './sections.mjs';
27
- import { sectionInView, spyLine } from '../lib/section-in-view.mjs';
27
+ import { useFormSections } from './formSections';
28
28
  import { publicPath, shownAt, useUploads } from './images';
29
29
  import { filerAddresses, useMediaPicker } from './Media';
30
30
  import { autoTagChanges, newlyUsed } from './autoTags.mjs';
@@ -117,19 +117,23 @@ function valuesOf(collection: MarkdownCollection, file: RepoFile): Record<string
117
117
  );
118
118
  }
119
119
 
120
- // Scrolling to a part of the form: smoothly, unless less motion is asked for.
121
- const scrolling = (): ScrollBehavior => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth');
122
-
123
120
  const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
124
121
 
125
122
  // The values as they are saved: a field only in use with another's value (Field.only: "Tidspunkt" with
126
- // "Planlagt") is empty without it, so it leaves the file; a switch that is on when its key is missing (Field.missing,
127
- // "Sidekolonne") leaves the file when it is on (booleanWrite).
128
- const inForm = (fields: Field[], values: Record<string, unknown>) =>
123
+ // "Planlagt") is empty without it, so it leaves the file; a switch that is on when its key is missing (Field.missing)
124
+ // leaves the file when it is on (booleanWrite); a field of the theme's templates (Field.offered) leaves it for the theme's
125
+ // default, `offered` (templateWritten).
126
+ const inForm = (fields: Field[], values: Record<string, unknown>, offered: string[]) =>
129
127
  Object.fromEntries(
130
128
  fields.map((f) => [
131
129
  f.name,
132
- inUse(f, fields, values) ? (f.type === 'boolean' ? booleanWrite(values[f.name], f.missing) : values[f.name]) : '',
130
+ !inUse(f, fields, values)
131
+ ? ''
132
+ : f.offered === 'templates'
133
+ ? templateWritten(values[f.name], offered)
134
+ : f.type === 'boolean'
135
+ ? booleanWrite(values[f.name], f.missing)
136
+ : values[f.name],
133
137
  ]),
134
138
  );
135
139
 
@@ -200,8 +204,10 @@ function Form({
200
204
  const [savedAt, setSavedAt] = useSavedAt(env, file.path);
201
205
 
202
206
  const isNew = !base.path;
203
- const asSaved = useMemo(() => inForm(collection.fields, values), [collection, values]);
204
- const was = useMemo(() => inForm(collection.fields, initial), [collection, initial]);
207
+ // The templates the theme chosen offers, for the field `offered: 'templates'.
208
+ const offered = useMemo(() => offeredTemplatesOf(env.files), [env.files]);
209
+ const asSaved = useMemo(() => inForm(collection.fields, values, offered), [collection, values, offered]);
210
+ const was = useMemo(() => inForm(collection.fields, initial, offered), [collection, initial, offered]);
205
211
  const changed = collection.fields.filter((f) => !same(asSaved[f.name], was[f.name]));
206
212
  const dirty = bodyChanged || changed.length > 0;
207
213
 
@@ -225,64 +231,13 @@ function Form({
225
231
  ...(isNew ? [] : [{ id: 'versions', label: t.history.versions }]),
226
232
  ];
227
233
  const shows = (id: Section) => sections.some((s) => s.id === id);
228
- // The section in view, marked in the sections list: while the page scrolls, the last one whose top is above a third of the
229
- // screen (at the bottom of the page, the last one in view). A tap in the list marks its own and scrolls there; the
230
- // mark stays meanwhile (`steering`, until the scroll ends).
231
- const [section, setSection] = useState<Section>('content');
232
- const boxes = useRef<Partial<Record<Section, HTMLElement | null>>>({});
233
- const order = useRef<Section[]>([]);
234
- order.current = sections.map((s) => s.id as Section);
235
- const steering = useRef(0);
236
- const spy = useRef(() => {});
237
- const steer = (id: Section) => {
238
- setSection(id);
239
- steering.current = performance.now() + 1500;
240
- addEventListener('scrollend', () => (steering.current = 0), { once: true });
241
- };
242
- // The sections that are open; at first only the text (and "Søgemaskiner" when it is opened at it, `opening`).
243
- const [open, setOpen] = useState<ReadonlySet<Section>>(() => new Set(['content', ...(opening === 'seo' ? ['seo' as const] : [])]));
244
- const unfold = (id: Section) => setOpen((was) => (was.has(id) ? was : new Set([...was, id])));
245
- // A section opened by its heading is marked in the sections list; one folded, the one in view now (the page is shorter,
246
- // without a scroll).
247
- const toggle = (id: Section) => {
248
- const opening = !open.has(id);
249
- setOpen((was) => {
250
- const next = new Set(was);
251
- if (opening) next.add(id);
252
- else next.delete(id);
253
- return next;
254
- });
255
- if (opening) setSection(id);
256
- else requestAnimationFrame(() => spy.current());
257
- };
258
- useEffect(() => {
259
- let frame = 0;
260
- spy.current = () => {
261
- frame = 0;
262
- if (performance.now() < steering.current) return;
263
- const atBottom = innerHeight + scrollY >= document.documentElement.scrollHeight - 8;
264
- const tops: Record<string, number | undefined> = {};
265
- for (const id of order.current) tops[id] = boxes.current[id]?.getBoundingClientRect().top;
266
- const here = sectionInView(order.current, tops, spyLine(innerHeight, atBottom));
267
- if (here) setSection(here as Section);
268
- };
269
- const onScroll = () => {
270
- if (!frame) frame = requestAnimationFrame(spy.current);
271
- };
272
- addEventListener('scroll', onScroll, { passive: true });
273
- return () => {
274
- removeEventListener('scroll', onScroll);
275
- cancelAnimationFrame(frame);
276
- };
277
- }, []);
278
- // Opens a section and scrolls to it (or to `box` in it: the text). It is opened at once (flushSync), so it is there to scroll to.
234
+ // The sections: which are open, at first only the text (and "Søgemaskiner" when it is opened at it, `opening`); the one in
235
+ // view, marked in the sections list; a tap there opens one and scrolls to it (formSections.ts).
236
+ const { section, steer, unfold, goTo, pick, sectionProps } = useFormSections<Section>(sections, [
237
+ 'content',
238
+ ...(opening === 'seo' ? ['seo' as const] : []),
239
+ ]);
279
240
  const textBox = useRef<HTMLDivElement>(null);
280
- const goTo = (id: Section, box?: HTMLElement | null) => {
281
- if (!open.has(id)) flushSync(() => unfold(id));
282
- steer(id);
283
- requestAnimationFrame(() => (box ?? boxes.current[id])?.scrollIntoView({ block: 'start', behavior: scrolling() }));
284
- };
285
- const pick = (id: string) => goTo(id as Section);
286
241
  // Opened from "SEO-vurdering" (SeoScores.tsx): straight to "Søgemaskiner" (open from the start, `open`).
287
242
  useEffect(() => {
288
243
  if (opening !== 'seo') return;
@@ -294,13 +249,6 @@ function Form({
294
249
  useEffect(() => {
295
250
  if (wrongField.field) steer(sectionOf(wrongField.field.path));
296
251
  }, [wrongField.field?.at]);
297
- // A section: where it is, for the sections list, its heading (its name in the list), and whether it is open.
298
- const sectionProps = (id: Section) => ({
299
- ref: (el: HTMLElement | null) => void (boxes.current[id] = el),
300
- title: sections.find((s) => s.id === id)!.label,
301
- open: open.has(id),
302
- onToggle: () => toggle(id),
303
- });
304
252
 
305
253
  // The SEO rating at the top of "Søgemaskiner" (seo.ts), again with every change of a field while that is open and near
306
254
  // the screen (`seoBox`, what is in the section, is hidden while it is folded); the same for the preview of a shared link
@@ -460,7 +408,7 @@ function Form({
460
408
 
461
409
  // A new file gets all its filled-in fields in the order of the form; an existing one only the changed. As saved:
462
410
  // the values given (a new one without a title has its name, "Ny side 1", named()), not only those in the form.
463
- const saved = inForm(collection.fields, values);
411
+ const saved = inForm(collection.fields, values, offered);
464
412
  const written = (isNew ? collection.fields.filter((f) => !same(saved[f.name], []) && f.type !== 'readonly') : changed).filter(
465
413
  (f) => f.name !== 'path',
466
414
  );
@@ -597,7 +545,7 @@ function Form({
597
545
  }
598
546
 
599
547
  // "Forhåndsvis": the page or post with what is changed in the form; a new one, or one whose layout is changed in the
600
- // form (a field with `layout`, "Hele bredden"), in the page of another one of its kind saved with that layout, with
548
+ // form (a field with `layout`, the template), in the page of another one of its kind saved with that layout, with
601
549
  // all of the form put in it (pagePreview.tsx).
602
550
  const [openPreview, previewSheet] = usePagePreview();
603
551
  const showPreview = () =>
@@ -606,9 +554,11 @@ function Form({
606
554
  const relaid = !isNew && changed.some((f) => f.layout);
607
555
  const sameLayout = (data: Record<string, unknown>) =>
608
556
  layout.every((f) =>
609
- f.type === 'boolean'
610
- ? booleanRead(data[f.name], f.missing) === (values[f.name] === true)
611
- : String(data[f.name] || '') === String(values[f.name] || ''),
557
+ f.offered === 'templates'
558
+ ? templateOf({ template: data[f.name] }, offered) === templateOf({ template: values[f.name] }, offered)
559
+ : f.type === 'boolean'
560
+ ? booleanRead(data[f.name], f.missing) === (values[f.name] === true)
561
+ : String(data[f.name] || '') === String(values[f.name] || ''),
612
562
  );
613
563
  const changes = (...names: string[]) => isNew || relaid || changed.some((f) => names.includes(f.name));
614
564
  const others =
@@ -648,11 +598,14 @@ function Form({
648
598
  set('date', String(v).slice(0, 10));
649
599
  };
650
600
  // The fields in use (Field.only).
651
- // "Sidekolonne" only while src/data/side.json chooses a block and the page is not "Hele bredden" (sideSwitchShown).
652
- const sideShown = sideSwitchShown({ block: sideSettingOf(env.files) }, values);
653
- const field = (f: Field) =>
654
- inUse(f, collection.fields, values) &&
655
- (f.name !== 'side' || sideShown) && <FieldInput key={f.name} field={f} value={values[f.name]} onChange={(v) => onChange(f, v)} />;
601
+ // A field of the theme's templates (Field.offered) shows the options the theme offers, and not while only one is left.
602
+ const field = (f: Field) => {
603
+ if (!inUse(f, collection.fields, values)) return false;
604
+ const shown = f.offered ? templateFieldOf(f, env.files) : f;
605
+ if (!shown) return false;
606
+ const value = f.offered ? shownTemplate(shown, values[f.name]) : values[f.name];
607
+ return <FieldInput key={f.name} field={shown} value={value} onChange={(v) => onChange(f, v)} />;
608
+ };
656
609
 
657
610
  return (
658
611
  <Fields.Provider value={fields}>
@@ -0,0 +1,76 @@
1
+ // "Udseende" › "Footer" (#udseende/footer): the footer's text under the name, its two headings and its links (footer.json),
2
+ // on one screen laid out as a page's form (SettingsSections.tsx): a preview of the footer as the form has it, on a phone and
3
+ // a computer (partPreview.tsx), over the sections Text, Contact and Links. "Gem ændringer" saves the file in its own
4
+ // commit (useDataFiles.ts). Exposes FooterSettings (default). Uses fields.tsx and footerPreview.mjs.
5
+ import { useMemo } from 'react';
6
+ import FormFrame from './FormFrame';
7
+ import { FieldList, Fields, WrongField, type FieldContext } from './fields';
8
+ import { useDataFiles, type DataFilePart } from './useDataFiles';
9
+ import { SettingsSections, useSettingsSections, type SettingsSection } from './SettingsSections';
10
+ import { PartPreview } from './partPreview';
11
+ import { footerDraft, swapFooter } from './footerPreview.mjs';
12
+ import { collections } from './site';
13
+ import type { FilesCollection } from './schema';
14
+ import type { Env } from './App';
15
+ import { t } from './i18n';
16
+
17
+ // The footer's file, by its collection (defaults/collections.ts); a site without it has nothing to show.
18
+ const collection = collections.find((c): c is FilesCollection => c.id === 'footer' && c.kind === 'files');
19
+ const parts: DataFilePart[] = collection ? [{ collection, dataFile: collection.files[0] }] : [];
20
+
21
+ // The sections and the file's fields (by name) that each has.
22
+ const places = [
23
+ { id: 'text', label: t.library.footerSections.text, names: ['text'] },
24
+ { id: 'contact', label: t.library.footerSections.contact, names: ['contactHeading'] },
25
+ { id: 'links', label: t.library.footerSections.links, names: ['linksHeading', 'links'] },
26
+ ];
27
+ // The section a field is in (its path: "links.0.label").
28
+ const placeOf = (path: string) => places.find((p) => p.names.includes(path.split('.')[0])) ?? places[0];
29
+
30
+ // The class of a link in the footer (Footer.astro), for a footer whose page has no link to copy.
31
+ const THEME = { linkClass: 'md-footer-item' };
32
+
33
+ const back = { href: '#udseende', label: t.common.parts.appearance };
34
+
35
+ export default function FooterSettings({ env }: { env: Env }) {
36
+ const layout = useSettingsSections(places);
37
+ const form = useDataFiles(env, parts, (_file, path) => {
38
+ const { id } = placeOf(path);
39
+ layout.unfold(id);
40
+ layout.steer(id);
41
+ });
42
+ const context: FieldContext = { relations: env.relations, lists: env.lists, testimonials: env.testimonials, site: env.site };
43
+
44
+ // What the form changes, put into a copy of the site's front page each time it changes.
45
+ const swap = useMemo(() => {
46
+ const draft = footerDraft(form.initial[0] ?? {}, form.values[0] ?? {});
47
+ return (doc: Document) => swapFooter(doc, draft, THEME);
48
+ }, [form.initial, form.values]);
49
+
50
+ const sections: SettingsSection[] = places.map((p) => ({
51
+ id: p.id,
52
+ label: p.label,
53
+ content: parts[0] && (
54
+ <WrongField.Provider value={form.wrong(0)}>
55
+ <FieldList
56
+ fields={parts[0].dataFile.fields.filter((f) => p.names.includes(f.name))}
57
+ value={form.values[0]}
58
+ onChange={(v) => form.set(0, v as Record<string, unknown>)}
59
+ />
60
+ </WrongField.Provider>
61
+ ),
62
+ }));
63
+
64
+ return (
65
+ <Fields.Provider value={context}>
66
+ <FormFrame title={t.common.parts.footer} back={back} dirty={form.dirty} saving={form.saving} onSave={form.save}>
67
+ <SettingsSections
68
+ title={t.common.parts.footer}
69
+ top={<PartPreview part="footer" swap={swap} />}
70
+ sections={sections}
71
+ state={layout}
72
+ />
73
+ </FormFrame>
74
+ </Fields.Provider>
75
+ );
76
+ }
package/src/FormFrame.tsx CHANGED
@@ -215,12 +215,13 @@ export default function FormFrame<T extends string>({
215
215
  )}
216
216
  <Button
217
217
  disabled={saving}
218
+ busy={saving && !deleting}
218
219
  onClick={() => {
219
220
  askToNotify();
220
221
  void onSave();
221
222
  }}
222
223
  >
223
- {saving ? <LoadingIndicator>{deleting ? t.app.busy.deleting : t.app.busy.saving}</LoadingIndicator> : t.common.saveChanges}
224
+ {t.common.saveChanges}
224
225
  </Button>
225
226
  </div>
226
227
  </div>
@@ -0,0 +1,100 @@
1
+ // "Udseende" › "Header" (#udseende/header): what is in the header on every page, on one screen laid out as a page's form
2
+ // (SettingsSections.tsx): a preview of the header as the form has it, on a phone and a computer (partPreview.tsx), over the
3
+ // sections Main menu (menu.json), Mobile menu (mobile-menu.json), Action button and Menu button (header.json). "Gem
4
+ // ændringer" saves each changed file in its own commit (useDataFiles.ts). Exposes HeaderSettings (default). Uses
5
+ // DataFileFields.tsx, fields.tsx, blocks.ts (BlockData), headerPreview.mjs and headerTheme.tsx.
6
+ import { useMemo, type ReactNode } from 'react';
7
+ import FormFrame from './FormFrame';
8
+ import { FieldList, Fields, ObjectFields, WrongField, type FieldContext } from './fields';
9
+ import { DataFileFields } from './DataFileFields';
10
+ import { useDataFiles, type DataFilePart } from './useDataFiles';
11
+ import { SettingsSections, useSettingsSections, type SettingsSection } from './SettingsSections';
12
+ import { PartPreview } from './partPreview';
13
+ import type { BlockData } from './blocks';
14
+ import { headerDraft, swapHeader } from './headerPreview.mjs';
15
+ import { headerThemeOf } from './headerTheme';
16
+ import { collections } from './site';
17
+ import type { FilesCollection } from './schema';
18
+ import type { Env } from './App';
19
+ import { t } from './i18n';
20
+
21
+ // The header's three files, by their collections (defaults/collections.ts), as the preview names them.
22
+ const FILES = { menu: 'menu', mobileMenu: 'mobilmenu', header: 'headerknap' } as const;
23
+ const parts: DataFilePart[] = Object.values(FILES).flatMap((id) => {
24
+ const collection = collections.find((c): c is FilesCollection => c.id === id && c.kind === 'files');
25
+ return collection ? [{ collection, dataFile: collection.files[0] }] : [];
26
+ });
27
+ const at = (id: string) => parts.findIndex((p) => p.collection.id === id);
28
+
29
+ // The sections, each with the file it is of: a menu is one; header.json's group (the action button) is one, and its other
30
+ // fields (the menu button's side) are "Menu button".
31
+ type Place = { id: string; label: string; file: number; field?: string };
32
+ const places: Place[] = parts.flatMap(({ collection, dataFile }, file): Place[] => {
33
+ if (dataFile.root) return [{ id: collection.id, label: collection.label, file }];
34
+ const groups = dataFile.fields.filter((f) => f.type === 'object').map((f) => ({ id: f.name, label: f.label, file, field: f.name }));
35
+ const rest = dataFile.fields.some((f) => f.type !== 'object')
36
+ ? [{ id: `${collection.id}-rest`, label: t.library.headerMenuButton, file }]
37
+ : [];
38
+ return [...groups, ...rest];
39
+ });
40
+ // The section a file's field is in (its path: "button.label", "items.0.label").
41
+ function placeOf(file: number, path: string) {
42
+ const mine = places.filter((p) => p.file === file);
43
+ return mine.find((p) => p.field === path.split('.')[0]) ?? mine.find((p) => !p.field)!;
44
+ }
45
+
46
+ type Values = Record<string, unknown>[];
47
+ // The files' values as headerDraft takes them; a file the site has not got is empty.
48
+ const shapeOf = (values: Values) => ({
49
+ menu: values[at(FILES.menu)] ?? {},
50
+ mobileMenu: values[at(FILES.mobileMenu)] ?? {},
51
+ header: values[at(FILES.header)] ?? {},
52
+ });
53
+
54
+ const back = { href: '#udseende', label: t.common.parts.appearance };
55
+
56
+ export default function HeaderSettings({ env }: { env: Env }) {
57
+ const layout = useSettingsSections(places);
58
+ const form = useDataFiles(env, parts, (file, path) => {
59
+ const { id } = placeOf(file, path);
60
+ layout.unfold(id);
61
+ layout.steer(id);
62
+ });
63
+ const context: FieldContext = { relations: env.relations, lists: env.lists, testimonials: env.testimonials, site: env.site };
64
+ // What the theme draws the header's button from (headerThemeOf): the same site data, as the block cards have it.
65
+ const data: BlockData = { lists: env.lists, testimonials: env.testimonials, relations: env.relations, site: env.site };
66
+
67
+ // What the form changes, put into a copy of the site's front page each time it changes.
68
+ const swap = useMemo(() => {
69
+ const draft = headerDraft(shapeOf(form.initial), shapeOf(form.values));
70
+ return (doc: Document) => swapHeader(doc, draft, headerThemeOf(draft.button, data));
71
+ }, [form.initial, form.values]);
72
+
73
+ const fieldsOf = ({ file, field }: Place): ReactNode => {
74
+ const { dataFile } = parts[file];
75
+ const value = form.values[file];
76
+ const set = (v: unknown) => form.set(file, v as Record<string, unknown>);
77
+ if (dataFile.root) return <DataFileFields dataFile={dataFile} value={value} onChange={set} add />;
78
+ const own = dataFile.fields.find((f) => f.name === field);
79
+ if (own) return <ObjectFields field={own} value={value[own.name]} onChange={(v) => set({ ...value, [own.name]: v })} />;
80
+ return <FieldList fields={dataFile.fields.filter((f) => f.type !== 'object')} value={value} onChange={set} />;
81
+ };
82
+ const sections: SettingsSection[] = places.map((p) => ({
83
+ id: p.id,
84
+ label: p.label,
85
+ content: <WrongField.Provider value={form.wrong(p.file)}>{fieldsOf(p)}</WrongField.Provider>,
86
+ }));
87
+
88
+ return (
89
+ <Fields.Provider value={context}>
90
+ <FormFrame title={t.common.parts.header} back={back} dirty={form.dirty} saving={form.saving} onSave={form.save}>
91
+ <SettingsSections
92
+ title={t.common.parts.header}
93
+ top={<PartPreview part="header" swap={swap} />}
94
+ sections={sections}
95
+ state={layout}
96
+ />
97
+ </FormFrame>
98
+ </Fields.Provider>
99
+ );
100
+ }
package/src/Library.tsx CHANGED
@@ -5,7 +5,8 @@
5
5
  // and posts can be filtered by their status and by what they pick (a post's tags), a list of what is
6
6
  // shown on pages (Promovering) by a page, its kind and whether it is on, as "Filer" (Filters). In a list that can be deleted from, the clock in the top bar ("Slettede …") opens
7
7
  // what was deleted (Deleted.tsx), as in "Filer". The tabs the apps installed add to a list (AppDefinition.tabs, Cookiescanner's
8
- // "Problemer" on "Cookies") are in its top app bar after the list's own, each at #liste/<list>/<app>.
8
+ // "Problemer" on "Cookies") are in its top app bar after the list's own, each at #liste/<list>/<app>. "Use theme" and the move arrows show the
9
+ // loading indicator while they run (only the pressed one).
9
10
  import { Fragment, useEffect, useMemo, useState, type ReactNode } from 'react';
10
11
  import {
11
12
  ArrowDown,
@@ -126,7 +127,8 @@ const countOf = (c: Collection, files: RepoFile[]): number =>
126
127
  export function partOf(id: string, files: RepoFile[]): { label: string; href: string; count: number | null } {
127
128
  const { parts } = t.common;
128
129
  if (id === 'virksomheden') return { label: parts.company, href: '#virksomheden', count: null };
129
- if (id === 'header') return { label: parts.header, href: '#header', count: null };
130
+ if (id === 'header') return { label: parts.header, href: '#udseende/header', count: null };
131
+ if (id === 'footer') return { label: parts.footer, href: '#udseende/footer', count: null };
130
132
  if (id === 'lister') return { label: parts.lists, href: '#lister', count: listsShown(files).length };
131
133
  if (id === 'blokke') return { label: parts.blocks, href: '#blokke', count: blockParts.length };
132
134
  if (id === 'filer') return { label: parts.media, href: '#filer', count: mediaOf(files).length + filesOf(files).length };
@@ -210,9 +212,9 @@ export function Company({ part, files }: { part?: Part; files: RepoFile[] }) {
210
212
  // "Udseende": first "Temaer" (Themes), with the theme the site is built with at the end of its row (or, after a swap,
211
213
  // until the site is built with the new one, that one), "Ikonpakker" (IconPacks), with the pack chosen, "Bevægelse"
212
214
  // (MotionSchemes), with the scheme chosen, and "Sidekolonne" (SideColumn.tsx), with the block chosen; then how the
213
- // site looks, the colours (Colors.tsx), the logo and
214
- // icon (BrandForm.tsx) and the site's own parts there (appearance in packages/editor/defaults/editor.ts: "Standardsider", a screen of its
215
- // own, CompanyForm.tsx, going back here), and how the editor and the blocks look, their parts ("Dele", PartsHome), each on a
215
+ // site looks, the colours (Colors.tsx), the logo and icon (BrandForm.tsx), the header with its menus and its preview
216
+ // (HeaderSettings.tsx), the footer with its preview (FooterSettings.tsx) and the site's own parts there (appearance in packages/editor/defaults/editor.ts: "Standardsider", a
217
+ // screen of its own, CompanyForm.tsx, going back here), and how the editor and the blocks look, their parts ("Dele", PartsHome), each on a
216
218
  // screen of its own, with the icons the site gives them.
217
219
  // The blocks that can be put in are under "Blokke" ("Blokindstillinger", BlockSettings.tsx).
218
220
  export function Theme({ files }: { files: RepoFile[] }) {
@@ -234,6 +236,8 @@ export function Theme({ files }: { files: RepoFile[] }) {
234
236
  },
235
237
  { id: 'farver', label: t.common.parts.colors, icon: icons.farver, trailing: <ThemeColors files={files} /> },
236
238
  { id: 'logo', label: t.common.parts.brand, icon: icons.logo },
239
+ { id: 'header', label: t.common.parts.header, icon: icons.header },
240
+ { id: 'footer', label: t.common.parts.footer, icon: icons.footer },
237
241
  ...appearance,
238
242
  { id: 'dele', label: t.common.parts.editorParts, icon: icons.dele },
239
243
  ];
@@ -473,7 +477,7 @@ export function ThemePage({ of, env, files }: { of: ThemeInfo; env: Env; files:
473
477
  </div>
474
478
  {of.id !== chosen && themeInstalled(of.id) && (
475
479
  <div className="mb-8">
476
- <Button disabled={saving} onClick={use}>
480
+ <Button busy={saving} disabled={saving} onClick={use}>
477
481
  {t.library.useTheme}
478
482
  </Button>
479
483
  </div>
@@ -695,8 +699,8 @@ function PartGroup({ title, route, parts }: { title?: string; route: string; par
695
699
  );
696
700
  }
697
701
 
698
- // A screen with parts of the site as rows, under one on the start screen: "Header" (the menus) and
699
- // "Lister" (the short lists: the site's, testimonials, and those of the apps installed, prices), A–Å as on the start screen.
702
+ // A screen with parts of the site as rows, under one on the start screen: "Lister" (the short lists: the site's,
703
+ // testimonials, and those of the apps installed, prices), A–Å as on the start screen.
700
704
  function PartsOf({ title, ids, files }: { title: string; ids: string[]; files: RepoFile[] }) {
701
705
  return (
702
706
  <Screen title={title} wide back={{ href: '#', label: siteName }}>
@@ -709,7 +713,6 @@ function PartsOf({ title, ids, files }: { title: string; ids: string[]; files: R
709
713
  );
710
714
  }
711
715
 
712
- export const Header = ({ files }: { files: RepoFile[] }) => <PartsOf title={t.common.parts.header} ids={header} files={files} />;
713
716
  export const Lists = ({ files }: { files: RepoFile[] }) => <PartsOf title={t.common.parts.lists} ids={listsShown(files)} files={files} />;
714
717
 
715
718
  // The account: who is logged in, light or dark, the language, and "Log ud".
@@ -1045,7 +1048,9 @@ export function List({ collection, tab, files, env }: { collection: Collection;
1045
1048
  const appTabs = tabsOf(collection.id, apps, installedApps(files));
1046
1049
  const appTab = appTabs.find((a) => a.app.id === tab);
1047
1050
  const [query, setQuery] = useKept(`${collection.id}-soeg`, '');
1048
- const [busy, setBusy] = useState(false);
1051
+ // The move that runs (row and target row, null: none); only its arrow shows busy, all arrows stay disabled meanwhile.
1052
+ const [moving, setMoving] = useState<{ from: number; to: number } | null>(null);
1053
+ const busy = moving !== null;
1049
1054
  const items = useMemo(() => itemsOf(collection, files), [collection, files]);
1050
1055
  const pages = collection.id === 'pages';
1051
1056
  // Pages and posts: their address in a column on a computer, and a filter that shows only those with
@@ -1264,14 +1269,14 @@ export function List({ collection, tab, files, env }: { collection: Collection;
1264
1269
  async function move(i: number, to: number) {
1265
1270
  const next = items.map((item) => item.file!);
1266
1271
  [next[i], next[to]] = [next[to], next[i]];
1267
- setBusy(true);
1272
+ setMoving({ from: i, to });
1268
1273
  env.notify({ text: t.library.savingOrder });
1269
1274
  try {
1270
1275
  env.notify({ text: savedText(await env.reorder(collection, next)), kind: 'saved' });
1271
1276
  } catch (e) {
1272
1277
  env.notify({ text: t.library.orderNotSaved((e as Error).message), error: true, kind: 'failed' });
1273
1278
  } finally {
1274
- setBusy(false);
1279
+ setMoving(null);
1275
1280
  }
1276
1281
  }
1277
1282
 
@@ -1317,10 +1322,17 @@ export function List({ collection, tab, files, env }: { collection: Collection;
1317
1322
  actions={
1318
1323
  orderable && (
1319
1324
  <span className="flex shrink-0 pr-2">
1320
- <IconButton icon={ArrowUp} label={t.common.moveUp} disabled={i === 0 || busy} onClick={() => move(i, i - 1)} />
1325
+ <IconButton
1326
+ icon={ArrowUp}
1327
+ label={t.common.moveUp}
1328
+ busy={moving?.from === i && moving.to === i - 1}
1329
+ disabled={i === 0 || busy}
1330
+ onClick={() => move(i, i - 1)}
1331
+ />
1321
1332
  <IconButton
1322
1333
  icon={ArrowDown}
1323
1334
  label={t.common.moveDown}
1335
+ busy={moving?.from === i && moving.to === i + 1}
1324
1336
  disabled={i === shown.length - 1 || busy}
1325
1337
  onClick={() => move(i, i + 1)}
1326
1338
  />
@@ -1341,7 +1353,7 @@ export function List({ collection, tab, files, env }: { collection: Collection;
1341
1353
  : lists.includes(collection.id)
1342
1354
  ? { href: '#lister', label: t.common.parts.lists }
1343
1355
  : header.includes(collection.id)
1344
- ? { href: '#header', label: t.common.parts.header }
1356
+ ? { href: '#udseende/header', label: t.common.parts.header }
1345
1357
  : { href: '#', label: siteName }
1346
1358
  }
1347
1359
  tabs={