@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,97 @@
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, 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 { headerDraft, swapHeader } from './headerPreview.mjs';
14
+ import { headerThemeOf } from './headerTheme';
15
+ import { collections } from './site';
16
+ import type { FilesCollection } from './schema';
17
+ import type { Env } from './App';
18
+ import { t } from './i18n';
19
+
20
+ // The header's three files, by their collections (defaults/collections.ts), as the preview names them.
21
+ const FILES = { menu: 'menu', mobileMenu: 'mobilmenu', header: 'headerknap' } as const;
22
+ const parts: DataFilePart[] = Object.values(FILES).flatMap((id) => {
23
+ const collection = collections.find((c): c is FilesCollection => c.id === id && c.kind === 'files');
24
+ return collection ? [{ collection, dataFile: collection.files[0] }] : [];
25
+ });
26
+ const at = (id: string) => parts.findIndex((p) => p.collection.id === id);
27
+
28
+ // 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
29
+ // fields (the menu button's side) are "Menu button".
30
+ type Place = { id: string; label: string; file: number; field?: string };
31
+ const places: Place[] = parts.flatMap(({ collection, dataFile }, file): Place[] => {
32
+ if (dataFile.root) return [{ id: collection.id, label: collection.label, file }];
33
+ const groups = dataFile.fields.filter((f) => f.type === 'object').map((f) => ({ id: f.name, label: f.label, file, field: f.name }));
34
+ const rest = dataFile.fields.some((f) => f.type !== 'object')
35
+ ? [{ id: `${collection.id}-rest`, label: t.library.headerMenuButton, file }]
36
+ : [];
37
+ return [...groups, ...rest];
38
+ });
39
+ // The section a file's field is in (its path: "button.label", "items.0.label").
40
+ function placeOf(file: number, path: string) {
41
+ const mine = places.filter((p) => p.file === file);
42
+ return mine.find((p) => p.field === path.split('.')[0]) ?? mine.find((p) => !p.field)!;
43
+ }
44
+
45
+ type Values = Record<string, unknown>[];
46
+ // The files' values as headerDraft takes them; a file the site has not got is empty.
47
+ const shapeOf = (values: Values) => ({
48
+ menu: values[at(FILES.menu)] ?? {},
49
+ mobileMenu: values[at(FILES.mobileMenu)] ?? {},
50
+ header: values[at(FILES.header)] ?? {},
51
+ });
52
+
53
+ const back = { href: '#udseende', label: t.common.parts.appearance };
54
+
55
+ export default function HeaderSettings({ env }: { env: Env }) {
56
+ const layout = useSettingsSections(places);
57
+ const form = useDataFiles(env, parts, (file, path) => {
58
+ const { id } = placeOf(file, path);
59
+ layout.unfold(id);
60
+ layout.steer(id);
61
+ });
62
+ const context: FieldContext = { relations: env.relations, lists: env.lists, testimonials: env.testimonials, site: env.site };
63
+
64
+ // What the form changes, put into a copy of the site's front page each time it changes.
65
+ const swap = useMemo(() => {
66
+ const draft = headerDraft(shapeOf(form.initial), shapeOf(form.values));
67
+ return (doc: Document) => swapHeader(doc, draft, headerThemeOf(draft.button, context));
68
+ }, [form.initial, form.values]);
69
+
70
+ const fieldsOf = ({ file, field }: Place): ReactNode => {
71
+ const { dataFile } = parts[file];
72
+ const value = form.values[file];
73
+ const set = (v: unknown) => form.set(file, v as Record<string, unknown>);
74
+ if (dataFile.root) return <DataFileFields dataFile={dataFile} value={value} onChange={set} add />;
75
+ const own = dataFile.fields.find((f) => f.name === field);
76
+ if (own) return <ObjectFields field={own} value={value[own.name]} onChange={(v) => set({ ...value, [own.name]: v })} />;
77
+ return <FieldList fields={dataFile.fields.filter((f) => f.type !== 'object')} value={value} onChange={set} />;
78
+ };
79
+ const sections: SettingsSection[] = places.map((p) => ({
80
+ id: p.id,
81
+ label: p.label,
82
+ content: <WrongField.Provider value={form.wrong(p.file)}>{fieldsOf(p)}</WrongField.Provider>,
83
+ }));
84
+
85
+ return (
86
+ <Fields.Provider value={context}>
87
+ <FormFrame title={t.common.parts.header} back={back} dirty={form.dirty} saving={form.saving} onSave={form.save}>
88
+ <SettingsSections
89
+ title={t.common.parts.header}
90
+ top={<PartPreview part="header" swap={swap} />}
91
+ sections={sections}
92
+ state={layout}
93
+ />
94
+ </FormFrame>
95
+ </Fields.Provider>
96
+ );
97
+ }
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={
@@ -3,7 +3,7 @@
3
3
  // blocks (src/apps/<app>/blocks/) can be put in the text with a button; each is a card with the block as it looks
4
4
  // on the site, which opens a menu (Ret, hide on the site, move, Fjern), with a free line kept between
5
5
  // two cards to write in. A fixed page's sections are such a text too (SectionsField.tsx), so both look
6
- // and work the same.
6
+ // and work the same. The last tool, "Section settings", opens the section's sheet (BlockDialog.tsx).
7
7
  import {
8
8
  forwardRef,
9
9
  Fragment as ReactFragment,
@@ -43,7 +43,6 @@ import {
43
43
  LinkSimple,
44
44
  ListBullets,
45
45
  ListNumbers,
46
- Palette,
47
46
  Plus,
48
47
  Quotes,
49
48
  TextAa,
@@ -62,6 +61,7 @@ import { SECTION_COLORS } from '../lib/section-marker.mjs';
62
61
  import { sectionHeading, setSectionColor } from './sectionState.mjs';
63
62
  import { sectionFrame } from './sectionFrame';
64
63
  import { IMAGE, type BlockAsk } from './BlockDialog';
64
+ import SectionSettingsIcon from './SectionSettingsIcon';
65
65
  import { askLink } from './LinkSheet';
66
66
  import { Menu, MenuItem, Toolbar, ToolbarButton } from './ui';
67
67
  import { shownAt } from './images';
@@ -865,12 +865,6 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
865
865
  colorsAt?.setAttribute('aria-expanded', 'true');
866
866
  return () => colorsAt?.setAttribute('aria-expanded', 'false');
867
867
  }, [colorsAt]);
868
- // The button for the colour of a section, while its menu is open.
869
- const [sectionAt, setSectionAt] = useState<HTMLElement | null>(null);
870
- useEffect(() => {
871
- sectionAt?.setAttribute('aria-expanded', 'true');
872
- return () => sectionAt?.setAttribute('aria-expanded', 'false');
873
- }, [sectionAt]);
874
868
  // The previews of the blocks, drawn again when what they show from elsewhere changes: the lists,
875
869
  // the testimonials, the rest of the site, the page's address or a fixed page's other fields.
876
870
  const cards = useRef(new Set<() => void>());
@@ -1025,10 +1019,14 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1025
1019
  editor.current?.action((ctx) => paint(ctx.get(editorViewCtx), name));
1026
1020
  }
1027
1021
 
1028
- function sectionColor(name: string | null) {
1022
+ // The sheet with the settings of the section the cursor is in (its colour), then the colour set.
1023
+ async function sectionSettings() {
1024
+ const current = SECTION_COLORS.find((name) => active.includes(`section-${name}`)) ?? null;
1025
+ const answered = await (blocks ?? sheetProp)?.section(current);
1026
+ if (!answered) return;
1029
1027
  editor.current?.action((ctx) => {
1030
1028
  const view = ctx.get(editorViewCtx);
1031
- setSectionColor(view, name);
1029
+ setSectionColor(view, answered.color);
1032
1030
  view.focus();
1033
1031
  });
1034
1032
  }
@@ -1101,16 +1099,6 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1101
1099
  { label: t.text.bulletList, icon: ListBullets, action: () => setKind('bullet'), on: 'bullet' },
1102
1100
  { label: t.text.orderedList, icon: ListNumbers, action: () => setKind('ordered'), on: 'ordered' },
1103
1101
  { label: t.text.quote, icon: Quotes, action: () => setKind('quote'), on: 'quote' },
1104
- // The colour of the section the cursor's H2 starts (not a fixed page's text, whose H2s are its sections' names; the front page has it).
1105
- ...(framed && active.includes('section')
1106
- ? [
1107
- {
1108
- label: t.text.sectionColor,
1109
- icon: Palette,
1110
- action: (e: ReactMouseEvent<HTMLButtonElement>) => setSectionAt(sectionAt ? null : e.currentTarget),
1111
- },
1112
- ]
1113
- : []),
1114
1102
  // A picture and the blocks are put in with "+" (a sheet with them all); without blocks (a blog
1115
1103
  // post) a picture has its own button.
1116
1104
  ...(blocks
@@ -1120,11 +1108,16 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1120
1108
  : []),
1121
1109
  { label: t.text.undo, icon: ArrowUUpLeft, action: () => run(undoCommand) },
1122
1110
  { label: t.text.redo, icon: ArrowUUpRight, action: () => run(redoCommand) },
1111
+ // The settings of the section the cursor's H2 starts (not a fixed page's text, whose H2s are its sections' names; the front page has it).
1112
+ ...(framed && active.includes('section') && (blocks ?? sheetProp)
1113
+ ? [{ label: t.text.sectionSettings, icon: SectionSettingsIcon, action: sectionSettings }]
1114
+ : []),
1123
1115
  ];
1124
1116
 
1125
1117
  // When the toolbar is too short for all its buttons, the kinds of text (H1, H2, H3, T, the bulleted and
1126
1118
  // the numbered list and the quote) are one button: the one where the cursor is, with an arrow, and
1127
1119
  // the others under it.
1120
+ const hasSettings = tools.some((item) => item.icon === SectionSettingsIcon);
1128
1121
  const kinds = tools.filter((t) => ['h1', 'h2', 'h3', 'text', 'bullet', 'ordered', 'quote'].includes(t.on ?? ''));
1129
1122
  const kind = kinds.find((t) => active.includes(t.on!));
1130
1123
  const shown = kind ?? kinds.find((t) => t.on === 'text')!;
@@ -1135,18 +1128,18 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1135
1128
  const bar = toolbar.current!;
1136
1129
  // A button is 2.5rem, with 0.125rem between them and 0.375rem around them, and each of the lines
1137
1130
  // between the groups 0.5rem (the classes below).
1138
- const fits = () => setNarrow(bar.clientWidth < tools.length * 42 + 10 + 3 * 10);
1131
+ const fits = () => setNarrow(bar.clientWidth < tools.length * 42 + 10 + (3 + (hasSettings ? 1 : 0)) * 10);
1139
1132
  const watch = new ResizeObserver(fits);
1140
1133
  watch.observe(bar);
1141
1134
  return () => watch.disconnect();
1142
- }, [tools.length]);
1135
+ }, [tools.length, hasSettings]);
1143
1136
  const tool = ({ label, icon, action, on }: (typeof tools)[number]) => (
1144
1137
  <ToolbarButton key={label} icon={icon} label={label} selected={on ? active.includes(on) : undefined} onClick={action} />
1145
1138
  );
1146
1139
 
1147
1140
  // The lines between the marks on the text, the kinds of text, "+" (or "Billede") and "Fortryd".
1148
1141
  const lineBefore = (item: (typeof tools)[number]) =>
1149
- item === kinds[0] || item.icon === Plus || item.icon === Image || item.icon === ArrowUUpLeft;
1142
+ item === kinds[0] || item.icon === Plus || item.icon === Image || item.icon === ArrowUUpLeft || item.icon === SectionSettingsIcon;
1150
1143
  // A button of the toolbar; when it is too short, the kinds of text are one button (at the first), with a menu.
1151
1144
  const button = (item: (typeof tools)[number]) =>
1152
1145
  !narrow || !kinds.includes(item) ? (
@@ -1164,13 +1157,13 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1164
1157
 
1165
1158
  return (
1166
1159
  <div className="rounded-panel border border-line bg-surface">
1167
- {/* Stays at the top while scrolling through the text, a little under the app's top bar (Screen.tsx,
1160
+ {/* A bar with round corners and room all round it, inside the text's box. Stays at the top while scrolling through the text, a little under the app's top bar (Screen.tsx,
1168
1161
  4rem and its line); on a phone one row that scrolls sideways. The buttons are centred, but
1169
1162
  start at the left edge when they do not fit (safe), so none is out of reach. */}
1170
1163
  <Toolbar
1171
1164
  ref={toolbar}
1172
1165
  label={t.text.tools}
1173
- className="editor-tools sticky top-[calc(4.5rem+1px+env(safe-area-inset-top))] z-10 justify-center-safe overflow-x-auto rounded-t-panel border-b border-line medium:flex-wrap"
1166
+ className="editor-tools sticky top-[calc(5rem+1px+env(safe-area-inset-top))] z-10 m-2 justify-center-safe overflow-x-auto rounded-panel border border-line medium:flex-wrap"
1174
1167
  >
1175
1168
  {tools.map((tool) => (
1176
1169
  <ReactFragment key={tool.label}>
@@ -1191,17 +1184,6 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1191
1184
  <span className="text-ink">{t.text.noColor}</span>
1192
1185
  </MenuItem>
1193
1186
  </Menu>
1194
- {/* The theme's three colours for a section (section-marker.mjs), and "Ingen"; the one it has is marked. */}
1195
- <Menu anchor={sectionAt} label={t.text.sectionColor} onClose={() => setSectionAt(null)}>
1196
- <MenuItem icon={Palette} selected={!active.some((a) => a.startsWith('section-'))} onClick={() => sectionColor(null)}>
1197
- {t.text.sectionColors.none}
1198
- </MenuItem>
1199
- {SECTION_COLORS.map((name) => (
1200
- <MenuItem key={name} icon={Palette} selected={active.includes(`section-${name}`)} onClick={() => sectionColor(name)}>
1201
- {t.text.sectionColors[name as keyof typeof t.text.sectionColors]}
1202
- </MenuItem>
1203
- ))}
1204
- </Menu>
1205
1187
  <Menu anchor={kindsAt} label={t.text.kind} onClose={() => setKindsAt(null)}>
1206
1188
  {kinds.map(({ label, icon, action, on }) => (
1207
1189
  <MenuItem key={label} icon={icon} selected={active.includes(on!)} onClick={action as () => void}>
package/src/Media.tsx CHANGED
@@ -9,6 +9,7 @@
9
9
  // saved with the tick: the file moves, the pages that use it get the new address in the same commit,
10
10
  // and the old address sends on to it (addresses.ts). "+" uploads a new file, saved at once. The same
11
11
  // pictures (not the other files) are the gallery a picture is chosen from in the forms (useMediaPicker).
12
+ // The buttons that run (add, rename, delete, upload new) show the loading indicator while they do.
12
13
  // The funnel in Filer also filters by a tag (fileTagFilter.mjs; only tags some file has).
13
14
  // The clock in the top bar, "Slettede filer", opens the files that were deleted (Deleted.tsx).
14
15
  import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
@@ -395,6 +396,7 @@ export function useMediaPicker(media: RepoFile[], pictures: Uploads): [() => Pro
395
396
  kind="outlined"
396
397
 
397
398
  icon={UploadSimple}
399
+ busy={pictures.uploading}
398
400
  onClick={async () => {
399
401
  const added = await pictures.upload();
400
402
  if (added) done(publicPath(added.path));
@@ -634,6 +636,7 @@ export default function Media({ files, env, open }: { files: RepoFile[]; env: En
634
636
  icon={Check}
635
637
  label={t.media.saveName}
636
638
  kind="filled"
639
+ busy={renaming}
637
640
  disabled={renaming || name === stemOf(src) || !slugify(name) || slugify(name) === stemOf(src)}
638
641
  />
639
642
  </>
@@ -666,8 +669,8 @@ export default function Media({ files, env, open }: { files: RepoFile[]; env: En
666
669
  </div>
667
670
  <MediaTags key={src} src={src} files={files} env={env} />
668
671
  {code && !used.has(src) && (
669
- <Button kind="outlined" icon={Trash} disabled={deleting} onClick={() => remove(src)}>
670
- {deleting ? t.common.deleting : t.media.kinds[kindOf(src)].delete}
672
+ <Button kind="outlined" icon={Trash} busy={deleting} disabled={deleting} onClick={() => remove(src)}>
673
+ {t.media.kinds[kindOf(src)].delete}
671
674
  </Button>
672
675
  )}
673
676
  </>
@@ -686,7 +689,7 @@ export default function Media({ files, env, open }: { files: RepoFile[]; env: En
686
689
 
687
690
  onClick={() => (location.hash = 'slettet/filer')}
688
691
  />
689
- <IconButton icon={Plus} label={t.media.add} disabled={adding} onClick={add} />
692
+ <IconButton icon={Plus} label={t.media.add} busy={adding} disabled={adding} onClick={add} />
690
693
  </>
691
694
  }
692
695
  >
package/src/Redirects.tsx CHANGED
@@ -11,7 +11,7 @@
11
11
  // it off the list. One that has become a page or sends on since is not shown. Over each list, as over
12
12
  // the lists under "Indhold", the search, the order (kept in this browser; by address under the first
13
13
  // letter) and, over the redirects, the funnel that shows only those to one page or post, picked as a
14
- // chip.
14
+ // chip. The Save and Delete buttons show the loading indicator while their save runs (Forget does not).
15
15
  import { useEffect, useMemo, useRef, useState } from 'react';
16
16
  import { ArrowRight, ArrowsDownUp, Funnel, LinkBreak, Plus, Signpost, Trash, X } from '@phosphor-icons/react';
17
17
  import { addressOf, escape, REDIRECTS } from './addresses';
@@ -144,14 +144,16 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
144
144
 
145
145
  const dialog = useRef<HTMLDialogElement>(null);
146
146
  const [editing, setEditing] = useState<Editing | null>(null);
147
- const [saving, setSaving] = useState(false);
147
+ // Which store is running (null: none). Only the button that started it shows busy; all three stay disabled.
148
+ const [running, setRunning] = useState<'save' | 'delete' | null>(null);
149
+ const saving = running !== null;
148
150
  const open = (e: Editing) => {
149
151
  setEditing(e);
150
152
  dialog.current?.showModal();
151
153
  };
152
154
 
153
155
  // Saves the redirects as they are after a change, in one commit, none through another.
154
- async function store(next: AllRedirects, message: string, change: string): Promise<boolean> {
156
+ async function store(next: AllRedirects, message: string, change: string, kind: 'save' | 'delete'): Promise<boolean> {
155
157
  const flat = flatten(next);
156
158
  if (typeof flat === 'string') {
157
159
  env.notify({ text: flat, error: true });
@@ -159,7 +161,7 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
159
161
  }
160
162
  const changes = changesOf(flat, files!);
161
163
  if (!changes.length) return true;
162
- setSaving(true);
164
+ setRunning(kind);
163
165
  env.notify({ text: t.common.saving });
164
166
  try {
165
167
  const oids = await env.commit(message, changes, change);
@@ -174,7 +176,7 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
174
176
  env.notify({ text: t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
175
177
  return false;
176
178
  } finally {
177
- setSaving(false);
179
+ setRunning(null);
178
180
  }
179
181
  }
180
182
 
@@ -218,6 +220,7 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
218
220
  setRedirect(all, from, to),
219
221
  editing.isNew ? siteT.redirects.commit.add(from, to) : siteT.redirects.commit.change(from, to),
220
222
  t.app.changes[editing.isNew ? 'added' : 'edited'](t.app.changes.redirect(from)),
223
+ 'save',
221
224
  );
222
225
  if (!done) return;
223
226
  // Sent on: no longer an address that does not exist.
@@ -234,7 +237,7 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
234
237
  const flat = flatten(all);
235
238
  if (typeof flat === 'string') return env.notify({ text: flat, error: true });
236
239
  const change = t.app.changes.deleted(t.app.changes.redirect(from));
237
- if (await store(setRedirect(flat, from, null), siteT.redirects.commit.delete(from), change)) dialog.current?.close();
240
+ if (await store(setRedirect(flat, from, null), siteT.redirects.commit.delete(from), change, 'delete')) dialog.current?.close();
238
241
  }
239
242
 
240
243
  async function drop(path: string) {
@@ -430,8 +433,8 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
430
433
  onChange={(e) => setEditing({ ...editing, to: e.target.value })}
431
434
  />
432
435
  </div>
433
- <Button type="submit" disabled={saving}>
434
- {saving ? t.common.saving : m ? t.redirects.missing.sendOn : t.common.save}
436
+ <Button type="submit" busy={running === 'save'} disabled={saving}>
437
+ {m ? t.redirects.missing.sendOn : t.common.save}
435
438
  </Button>
436
439
  {m && (
437
440
  <Button kind="outlined" icon={X} disabled={saving} onClick={() => drop(m.path)}>
@@ -439,7 +442,7 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
439
442
  </Button>
440
443
  )}
441
444
  {!editing.isNew && (
442
- <Button kind="outlined" icon={Trash} disabled={saving} onClick={() => remove(editing.from)}>
445
+ <Button kind="outlined" icon={Trash} busy={running === 'delete'} disabled={saving} onClick={() => remove(editing.from)}>
443
446
  {t.redirects.deleteRedirect}
444
447
  </Button>
445
448
  )}
@@ -0,0 +1,21 @@
1
+ // The settings of a section in the sheet of BlockDialog.tsx: its colour, "None" and the theme's three (section-marker.mjs),
2
+ // the one it has marked. Exposes the default SectionSettings; uses Option (ui.tsx), SECTION_COLORS.
3
+ import { Option } from './ui';
4
+ import { SECTION_COLORS } from '../lib/section-marker.mjs';
5
+ import { t } from './i18n';
6
+
7
+ /** color: the section's colour, '' for none. */
8
+ export default function SectionSettings({ color, onChange }: { color: string; onChange: (color: string) => void }) {
9
+ return (
10
+ <div role="group" aria-label={t.text.sectionColor}>
11
+ <Option selected={!color} onClick={() => onChange('')}>
12
+ {t.text.sectionColors.none}
13
+ </Option>
14
+ {SECTION_COLORS.map((name) => (
15
+ <Option key={name} selected={color === name} onClick={() => onChange(name)}>
16
+ {t.text.sectionColors[name as keyof typeof t.text.sectionColors]}
17
+ </Option>
18
+ ))}
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,10 @@
1
+ // The icon of the "Section settings" tool: Material Symbols' calendar_view_day (Outlined), drawn by Svg (icon.tsx).
2
+ // Exposes the default SectionSettingsIcon, which takes the size and class the toolbar's buttons give an icon.
3
+ import type { Icon } from '@phosphor-icons/react';
4
+ import { Svg } from './icon';
5
+
6
+ const SectionSettingsIcon = (({ size = 20, className }: { size?: number | string; className?: string }) => (
7
+ <Svg name="calendar_view_day" size={Number(size)} className={className} />
8
+ )) as unknown as Icon;
9
+
10
+ export default SectionSettingsIcon;
@@ -0,0 +1,57 @@
1
+ // The layout of a settings screen with several parts (Appearance › Header, Appearance › Footer), as the page form's
2
+ // (EntryForm.tsx): `top` (the preview) over the sections, each folding in and out under its heading (FoldSection), with
3
+ // their "on this page" list at the window's end edge on large and wider screens (FormColumns, SectionPane, SectionList).
4
+ // The first is open from the start. On a phone (compact) only one is open at a time, an accordion: opening one folds the
5
+ // others; wider they open and fold each on its own, as a page's. Exposes useSettingsSections and SettingsSections. Uses
6
+ // formSections.ts and ui.tsx.
7
+ import type { ReactNode } from 'react';
8
+ import { FoldSection, FormColumns, type NavigationItem, SectionList, SectionPane, useAtLeast } from './ui';
9
+ import { useFormSections } from './formSections';
10
+ import { t } from './i18n';
11
+
12
+ /** A section of the screen: its id, its heading (its name in the list) and its fields. */
13
+ export type SettingsSection = NavigationItem & { content: ReactNode };
14
+
15
+ /** The sections' state, made by the screen, so a field that keeps it from being saved can open its section (`unfold`) and
16
+ * mark it in the list (`steer`) before it is scrolled to. */
17
+ export function useSettingsSections(sections: NavigationItem[]) {
18
+ const single = !useAtLeast('medium');
19
+ return useFormSections<string>(
20
+ sections,
21
+ sections.slice(0, 1).map((s) => s.id),
22
+ single,
23
+ );
24
+ }
25
+
26
+ export function SettingsSections({
27
+ title,
28
+ top,
29
+ sections,
30
+ state,
31
+ }: {
32
+ /** The screen's name, over the sections list. */
33
+ title: string;
34
+ top?: ReactNode;
35
+ sections: SettingsSection[];
36
+ state: ReturnType<typeof useSettingsSections>;
37
+ }) {
38
+ return (
39
+ <FormColumns
40
+ main={
41
+ <>
42
+ {top}
43
+ {sections.map((s) => (
44
+ <FoldSection key={s.id} {...state.sectionProps(s.id)}>
45
+ {s.content}
46
+ </FoldSection>
47
+ ))}
48
+ </>
49
+ }
50
+ pane={
51
+ <SectionPane caption={t.forms.sections.title} title={title}>
52
+ <SectionList title={t.forms.sections.title} items={sections} value={state.section} onChange={state.pick} />
53
+ </SectionPane>
54
+ }
55
+ />
56
+ );
57
+ }