@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
@@ -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
  )}
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>