@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.
- package/CHANGELOG.md +24 -1
- package/defaults/collections.ts +112 -58
- package/defaults/company.ts +1 -1
- package/defaults/editor.ts +2 -2
- package/defaults/texts.ts +14 -5
- package/demo/src/content/pages/app-store.md +0 -1
- package/demo/src/content/pages/blocks.md +0 -1
- package/icons/lucide/arrows-horizontal.svg +1 -0
- package/icons/lucide/columns-plus-left.svg +1 -0
- package/icons/lucide/columns-plus-right.svg +1 -0
- package/icons/lucide/file.svg +1 -0
- package/icons/material-symbols/arrows-horizontal.svg +1 -0
- package/icons/material-symbols/columns-plus-left.svg +1 -0
- package/icons/material-symbols/columns-plus-right.svg +1 -0
- package/icons/material-symbols/file.svg +1 -0
- package/icons/phosphor/arrows-horizontal.svg +1 -0
- package/icons/phosphor/columns-plus-left.svg +1 -0
- package/icons/phosphor/columns-plus-right.svg +1 -0
- package/icons/phosphor/file.svg +1 -0
- package/lib/fold-open.d.mts +2 -0
- package/lib/fold-open.mjs +22 -0
- package/lib/fold-open.test.mjs +24 -0
- package/lib/menu.test.mjs +16 -3
- package/lib/page-template.d.mts +54 -0
- package/lib/page-template.mjs +126 -0
- package/lib/page-template.test.mjs +129 -0
- package/lib/side-block.d.mts +2 -16
- package/lib/side-block.mjs +12 -30
- package/lib/side-block.test.mjs +13 -30
- package/package.json +1 -1
- package/scripts/check-side.mjs +49 -22
- package/scripts/editor-roundtrip.mjs +15 -6
- package/scripts/fixtures/side/side-full.md +8 -0
- package/scripts/fixtures/side/side-left.md +8 -0
- package/scripts/fixtures/side/side-none.md +7 -0
- package/scripts/fixtures/side/side-right.md +8 -0
- package/scripts/fixtures/side/side-single.md +8 -0
- package/scripts/side-width.mjs +29 -0
- package/scripts/side-width.test.mjs +30 -0
- package/site/content.config.ts +3 -4
- package/site/header.d.mts +21 -0
- package/site/header.mjs +35 -0
- package/site/header.test.mjs +59 -0
- package/site/icons.d.mts +16 -0
- package/site/icons.mjs +8 -0
- package/site/site.ts +9 -4
- package/site/templates.ts +7 -0
- package/site/theme.mjs +3 -0
- package/site/tracking.client.ts +4 -4
- package/src/App.tsx +12 -24
- package/src/AppList.tsx +36 -14
- package/src/AppSettingsTab.tsx +5 -3
- package/src/BlockDialog.tsx +50 -5
- package/src/DataFileFields.tsx +35 -0
- package/src/DataForm.tsx +16 -38
- package/src/Deleted.tsx +7 -3
- package/src/EntryForm.tsx +41 -88
- package/src/FooterSettings.tsx +76 -0
- package/src/FormFrame.tsx +2 -1
- package/src/HeaderSettings.tsx +100 -0
- package/src/Library.tsx +26 -14
- package/src/MarkdownEditor.tsx +18 -36
- package/src/Media.tsx +6 -3
- package/src/Redirects.tsx +12 -9
- package/src/SectionSettings.tsx +21 -0
- package/src/SectionSettingsIcon.tsx +10 -0
- package/src/SettingsSections.tsx +57 -0
- package/src/ThemeStore.tsx +28 -9
- package/src/Versions.tsx +3 -15
- package/src/busyAction.d.mts +3 -0
- package/src/busyAction.mjs +8 -0
- package/src/busyAction.test.mjs +16 -0
- package/src/dataFileValues.ts +30 -0
- package/src/editor.css +42 -3
- package/src/fields.tsx +29 -10
- package/src/footerPreview.d.mts +46 -0
- package/src/footerPreview.mjs +84 -0
- package/src/footerPreview.test.mjs +100 -0
- package/src/formSections.ts +100 -0
- package/src/frontmatter.mjs +1 -1
- package/src/frontmatter.test.mjs +11 -11
- package/src/headerPreview.d.mts +48 -0
- package/src/headerPreview.mjs +168 -0
- package/src/headerPreview.test.mjs +183 -0
- package/src/headerTheme.tsx +58 -0
- package/src/i18n/da-DK/common.ts +1 -0
- package/src/i18n/da-DK/library.ts +2 -0
- package/src/i18n/da-DK/preview.ts +2 -0
- package/src/i18n/da-DK/text.ts +1 -0
- package/src/i18n/en-US/common.ts +1 -0
- package/src/i18n/en-US/library.ts +4 -0
- package/src/i18n/en-US/preview.ts +3 -0
- package/src/i18n/en-US/text.ts +2 -1
- package/src/images.ts +7 -2
- package/src/menuChildren.ts +1 -3
- package/src/movedRoutes.d.mts +3 -0
- package/src/movedRoutes.mjs +35 -0
- package/src/movedRoutes.test.mjs +22 -0
- package/src/pagePreview.tsx +40 -29
- package/src/partPreview.tsx +59 -0
- package/src/schema.ts +7 -1
- package/src/sheetPlacement.d.mts +3 -0
- package/src/sheetPlacement.mjs +9 -0
- package/src/sheetPlacement.test.mjs +23 -0
- package/src/standard/views.ts +3 -3
- package/src/templateField.ts +28 -0
- package/src/themes.ts +3 -0
- package/src/ui.tsx +35 -8
- package/src/useDataFiles.ts +94 -0
- package/starter/package.json +1 -1
- package/theme/components/Footer.astro +14 -7
- package/theme/components/Header.astro +58 -33
- package/theme/components/MobileNav.astro +5 -4
- package/theme/components/RelatedPosts.astro +1 -1
- package/theme/components/headerParts.ts +17 -0
- package/theme/layouts/PageLayout.astro +20 -16
- package/theme/styles/base.css +6 -5
- package/theme/styles/blocks.css +8 -12
- package/theme/styles/components.css +8 -1
- package/theme/styles/global.css +5 -9
- package/theme/styles/ui.css +111 -5
- package/theme/theme.json +1 -0
- package/theme/ui/BusyContent.tsx +16 -0
- package/theme/ui/Button.tsx +25 -5
- package/theme/ui/IconButton.tsx +17 -4
- package/scripts/fixtures/side/side-off.md +0 -8
- package/scripts/fixtures/side/side-plain.md +0 -7
- 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 {
|
|
20
|
-
import {
|
|
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 {
|
|
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
|
-
//
|
|
128
|
-
|
|
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)
|
|
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
|
-
|
|
204
|
-
const
|
|
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
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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`,
|
|
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.
|
|
610
|
-
?
|
|
611
|
-
:
|
|
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
|
-
//
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
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: "
|
|
699
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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={
|