@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.
- package/CHANGELOG.md +17 -1
- package/defaults/collections.ts +110 -56
- 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 +8 -3
- package/site/templates.ts +7 -0
- package/site/theme.mjs +3 -0
- 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 +97 -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 +37 -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/BlockDialog.tsx
CHANGED
|
@@ -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={
|
|
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
|
-
|
|
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={
|
|
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
|
|
23
|
-
import { FieldInput, FieldList, 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
|
|
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.
|
|
315
|
-
? { href: '#', label:
|
|
316
|
-
:
|
|
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
|
|
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
|
-
{
|
|
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 {
|
|
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>
|