@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/ThemeStore.tsx
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
// it); and on the page of one installed from the store (ThemePage in Library.tsx) "Opdater til …" and "Afinstaller"
|
|
6
6
|
// (ThemeStoreButtons). Installing copies its folder into the site's themes (config.themes/<id>/, with its store.json),
|
|
7
7
|
// updating copies the newer version over it and uninstalling deletes it, each saved at once in its own commit
|
|
8
|
-
// (themeChanges)
|
|
9
|
-
// the site is changing to and the standard theme
|
|
8
|
+
// (themeChanges), the button pressed showing the loading indicator while it runs; the site's next build has it, and
|
|
9
|
+
// only then can it be chosen ("Brug temaet"). The theme in use, the one the site is changing to and the standard theme
|
|
10
|
+
// cannot be uninstalled.
|
|
10
11
|
import { useEffect, useState } from 'react';
|
|
11
12
|
import { Check, Smiley } from '@phosphor-icons/react';
|
|
12
13
|
import Screen from './Screen';
|
|
14
|
+
import { busyFor } from './busyAction.mjs';
|
|
13
15
|
import { Button, EmptyNote, Group, GroupTitle, ImageCarousel, SupportingText, ListItem } from './ui';
|
|
14
16
|
import { ConflictError, type RepoFile } from './github';
|
|
15
17
|
import type { Env } from './App';
|
|
@@ -106,19 +108,24 @@ const themeOfPack = (files: RepoFile[]) => iconPacks.find((x) => x.id === chosen
|
|
|
106
108
|
const canUninstall = (id: string, files: RepoFile[]) =>
|
|
107
109
|
id !== STANDARD_THEME && id !== theme.id && id !== chosenTheme(files) && id !== themeOfPack(files);
|
|
108
110
|
|
|
111
|
+
type ThemeAction = 'install' | 'update' | 'uninstall';
|
|
112
|
+
|
|
109
113
|
/** Installing, updating and uninstalling a theme from the store, each saved at once in its own commit (themeChanges).
|
|
110
114
|
* Each says true when it was saved. */
|
|
111
115
|
function useThemeActions(env: Env) {
|
|
112
|
-
|
|
116
|
+
// Which action runs, on which theme (busyAction.mjs shows only the pressed button).
|
|
117
|
+
const [running, setRunning] = useState<{ id: string; action: ThemeAction; asking: boolean } | null>(null);
|
|
118
|
+
const saving = !!running;
|
|
113
119
|
const recordOf = (x: ShownTheme): StoreRecord => ({ version: x.version, ref: x.version, files: x.files });
|
|
114
120
|
|
|
115
121
|
async function change(
|
|
116
122
|
id: string,
|
|
123
|
+
action: ThemeAction,
|
|
117
124
|
text: { busy: string; done: string; commit: string; change: string },
|
|
118
125
|
next: () => Promise<Parameters<typeof themeChanges>[2]>,
|
|
119
126
|
go: (current: RepoFile[]) => Promise<boolean> = async () => true,
|
|
120
127
|
): Promise<boolean> {
|
|
121
|
-
|
|
128
|
+
setRunning({ id, action, asking: false });
|
|
122
129
|
env.notify({ text: text.busy });
|
|
123
130
|
try {
|
|
124
131
|
const [current, wanted] = await Promise.all([env.folder(themeFolder(id)), next()]);
|
|
@@ -126,6 +133,9 @@ function useThemeActions(env: Env) {
|
|
|
126
133
|
env.notify(null);
|
|
127
134
|
return false;
|
|
128
135
|
}
|
|
136
|
+
// `go` cleared the message (and its dialog stopped the button being busy): saving goes on.
|
|
137
|
+
setRunning({ id, action, asking: false });
|
|
138
|
+
env.notify({ text: text.busy });
|
|
129
139
|
await env.commit(text.commit, themeChanges(id, current, wanted), text.change);
|
|
130
140
|
env.notify({ text: text.done, kind: 'saved' });
|
|
131
141
|
return true;
|
|
@@ -133,15 +143,17 @@ function useThemeActions(env: Env) {
|
|
|
133
143
|
env.notify({ text: e instanceof ConflictError ? e.message : t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
|
|
134
144
|
return false;
|
|
135
145
|
} finally {
|
|
136
|
-
|
|
146
|
+
setRunning(null);
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
return {
|
|
141
151
|
saving,
|
|
152
|
+
busy: (id: string, action: ThemeAction) => busyFor(running, id, action),
|
|
142
153
|
async install(x: ShownTheme) {
|
|
143
154
|
const done = await change(
|
|
144
155
|
x.id,
|
|
156
|
+
'install',
|
|
145
157
|
{
|
|
146
158
|
busy: t.apps.installing,
|
|
147
159
|
done: t.library.themeInstalled(x.name),
|
|
@@ -156,6 +168,7 @@ function useThemeActions(env: Env) {
|
|
|
156
168
|
async update(x: ShownTheme, record: StoreRecord) {
|
|
157
169
|
const done = await change(
|
|
158
170
|
x.id,
|
|
171
|
+
'update',
|
|
159
172
|
{
|
|
160
173
|
busy: t.apps.updating,
|
|
161
174
|
done: t.library.themeUpdated(x.name, x.version),
|
|
@@ -171,6 +184,7 @@ function useThemeActions(env: Env) {
|
|
|
171
184
|
env.notify({ text: t.library.themeDropsIconPack(x.name, pack.name), error: true });
|
|
172
185
|
return false;
|
|
173
186
|
}
|
|
187
|
+
setRunning({ id: x.id, action: 'update', asking: true });
|
|
174
188
|
return askUpdate(
|
|
175
189
|
x.name,
|
|
176
190
|
record.version,
|
|
@@ -187,6 +201,7 @@ function useThemeActions(env: Env) {
|
|
|
187
201
|
if (!(await ask(t.library.askUninstallTheme(name), t.apps.uninstall))) return false;
|
|
188
202
|
const done = await change(
|
|
189
203
|
id,
|
|
204
|
+
'uninstall',
|
|
190
205
|
{
|
|
191
206
|
busy: t.apps.uninstalling,
|
|
192
207
|
done: t.library.themeUninstalled(name),
|
|
@@ -277,7 +292,11 @@ export function StoreThemePage({ id, env }: { id: string; env: Env }) {
|
|
|
277
292
|
</div>
|
|
278
293
|
{!pending && (
|
|
279
294
|
<div className="mb-8">
|
|
280
|
-
<Button
|
|
295
|
+
<Button
|
|
296
|
+
disabled={actions.saving}
|
|
297
|
+
busy={actions.busy(of.id, 'install')}
|
|
298
|
+
onClick={async () => (await actions.install(of)) && redraw((n) => n + 1)}
|
|
299
|
+
>
|
|
281
300
|
{t.apps.install}
|
|
282
301
|
</Button>
|
|
283
302
|
</div>
|
|
@@ -314,8 +333,8 @@ export function StoreThemePage({ id, env }: { id: string; env: Env }) {
|
|
|
314
333
|
<div>
|
|
315
334
|
<Button
|
|
316
335
|
kind="outlined"
|
|
317
|
-
|
|
318
336
|
disabled={actions.saving}
|
|
337
|
+
busy={actions.busy(of.id, 'uninstall')}
|
|
319
338
|
onClick={async () => (await actions.uninstall(of.id, of.name)) && redraw((n) => n + 1)}
|
|
320
339
|
>
|
|
321
340
|
{t.apps.uninstall}
|
|
@@ -344,7 +363,7 @@ export function ThemeStoreButtons({ of, env, files, top }: { of: ThemeInfo; env:
|
|
|
344
363
|
if (top)
|
|
345
364
|
return update ? (
|
|
346
365
|
<div className="mb-8">
|
|
347
|
-
<Button disabled={actions.saving} onClick={() => actions.update(update, of.store!)}>
|
|
366
|
+
<Button disabled={actions.saving} busy={actions.busy(of.id, 'update')} onClick={() => actions.update(update, of.store!)}>
|
|
348
367
|
{t.apps.update(update.version)}
|
|
349
368
|
</Button>
|
|
350
369
|
</div>
|
|
@@ -354,8 +373,8 @@ export function ThemeStoreButtons({ of, env, files, top }: { of: ThemeInfo; env:
|
|
|
354
373
|
<div>
|
|
355
374
|
<Button
|
|
356
375
|
kind="outlined"
|
|
357
|
-
|
|
358
376
|
disabled={actions.saving || !free}
|
|
377
|
+
busy={actions.busy(of.id, 'uninstall')}
|
|
359
378
|
onClick={async () => (await actions.uninstall(of.id, of.name)) && redraw((n) => n + 1)}
|
|
360
379
|
>
|
|
361
380
|
{t.apps.uninstall}
|
package/src/Versions.tsx
CHANGED
|
@@ -6,19 +6,7 @@
|
|
|
6
6
|
// version, in a new commit, so nothing is lost and every version can always be found here again.
|
|
7
7
|
import { Component, Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
8
8
|
import { ClockCounterClockwise } from '@phosphor-icons/react';
|
|
9
|
-
import {
|
|
10
|
-
Button,
|
|
11
|
-
EmptyNote,
|
|
12
|
-
IconButton,
|
|
13
|
-
listClass,
|
|
14
|
-
Notice,
|
|
15
|
-
LoadingIndicator,
|
|
16
|
-
BottomSheet,
|
|
17
|
-
SheetTitle,
|
|
18
|
-
SheetBody,
|
|
19
|
-
ProblemNote,
|
|
20
|
-
ListItem,
|
|
21
|
-
} from './ui';
|
|
9
|
+
import { Button, EmptyNote, IconButton, listClass, Notice, BottomSheet, SheetTitle, SheetBody, ProblemNote, ListItem } from './ui';
|
|
22
10
|
import { mayLeave } from './FormFrame';
|
|
23
11
|
import { when } from './when';
|
|
24
12
|
import type { Env } from './App';
|
|
@@ -138,8 +126,8 @@ function VersionRows({ list, onPick }: { list: ReturnType<typeof useVersionList>
|
|
|
138
126
|
</ul>
|
|
139
127
|
)}
|
|
140
128
|
{more && (
|
|
141
|
-
<Button kind="outlined"
|
|
142
|
-
{
|
|
129
|
+
<Button kind="outlined" busy={loading} onClick={() => load(more)}>
|
|
130
|
+
{t.history.more}
|
|
143
131
|
</Button>
|
|
144
132
|
)}
|
|
145
133
|
</>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Which store action runs, for the buttons that show it: `running` is { id, action, asking } or null; `asking` is true
|
|
2
|
+
// while a dialog waits for the user. Exposes busyFor. No dependencies, so node can import it.
|
|
3
|
+
|
|
4
|
+
/** Whether the button of `action` ("install", "update" or "uninstall") on `id` shows the loading indicator: only the
|
|
5
|
+
* one pressed, and not while a dialog waits for the user. */
|
|
6
|
+
export function busyFor(running, id, action) {
|
|
7
|
+
return !!running && !running.asking && running.id === id && running.action === action;
|
|
8
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Tests for src/busyAction.mjs: only the pressed button is busy, and not while a dialog waits. Run: node --test
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { busyFor } from './busyAction.mjs';
|
|
5
|
+
|
|
6
|
+
test('busyFor is true for the running action on its id only', () => {
|
|
7
|
+
const running = { id: 'a', action: 'install', asking: false };
|
|
8
|
+
assert.equal(busyFor(running, 'a', 'install'), true);
|
|
9
|
+
assert.equal(busyFor(running, 'a', 'update'), false);
|
|
10
|
+
assert.equal(busyFor(running, 'b', 'install'), false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('busyFor is false when nothing runs or a dialog waits', () => {
|
|
14
|
+
assert.equal(busyFor(null, 'a', 'install'), false);
|
|
15
|
+
assert.equal(busyFor({ id: 'a', action: 'update', asking: true }, 'a', 'update'), false);
|
|
16
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A JSON file in a form: its fields, its values in the form (from its text) and its data again (to save), the same for one
|
|
2
|
+
// file on its own screen (DataForm.tsx) and several on one (useDataFiles.ts, Appearance › Header). Exposes fieldsOf,
|
|
3
|
+
// listOnly, valuesOf, dataOf and dataFileIn. Uses check.ts (withDefaults, clean) and schema.ts.
|
|
4
|
+
import { entryData, type Collection, type DataFile, type Field } from './schema';
|
|
5
|
+
import { clean, withDefaults } from './check';
|
|
6
|
+
import type { RepoFile } from './github';
|
|
7
|
+
|
|
8
|
+
/** A single file's fields: its own ("Aktiveret") and, when it is a list (the menus), the list's field after them. */
|
|
9
|
+
export const fieldsOf = (dataFile: DataFile): Field[] => (dataFile.root ? [...dataFile.fields, dataFile.root] : dataFile.fields);
|
|
10
|
+
|
|
11
|
+
/** A file that is only a list, without fields of its own beside it (a menu with "Aktiveret" is not). */
|
|
12
|
+
export const listOnly = (dataFile?: DataFile) => !!dataFile?.root && !dataFile.fields.length;
|
|
13
|
+
|
|
14
|
+
/** 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
|
|
15
|
+
* it now, entryData), or a list under the field's name. */
|
|
16
|
+
export function valuesOf(text: string, fields: Field[], dataFile: DataFile | undefined, collection: Collection, files: RepoFile[]) {
|
|
17
|
+
if (listOnly(dataFile)) return { [dataFile!.root!.name]: JSON.parse(text) } as Record<string, unknown>;
|
|
18
|
+
return withDefaults(fields, collection.kind === 'folder' && !dataFile ? entryData(collection, text, files) : JSON.parse(text));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The values as they are saved (clean): a file that is only a list as the list itself. */
|
|
22
|
+
export function dataOf(fields: Field[], values: Record<string, unknown>, dataFile?: DataFile): unknown {
|
|
23
|
+
const data = clean(fields, values);
|
|
24
|
+
return listOnly(dataFile) ? data[dataFile!.root!.name] : data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A single file as the site has it; an optional one (the header's) that the site has not got yet as an empty one, which
|
|
28
|
+
* the first save makes. */
|
|
29
|
+
export const dataFileIn = (files: RepoFile[], dataFile: DataFile): RepoFile | undefined =>
|
|
30
|
+
files.find((f) => f.path === dataFile.file) ?? (dataFile.optional ? { path: dataFile.file, oid: '', text: '{}\n' } : undefined);
|
package/src/editor.css
CHANGED
|
@@ -24,10 +24,14 @@
|
|
|
24
24
|
--editor-section-gap: 2rem;
|
|
25
25
|
--editor-section-rhythm: 0.625em;
|
|
26
26
|
|
|
27
|
-
/* A form's "on this page" area (SectionPane in ui.tsx) against the window's end edge: its width, the card in it
|
|
27
|
+
/* A form's "on this page" area (SectionPane in ui.tsx) against the window's end edge: its width (the card is as wide as the navigation drawer), the card in it
|
|
28
28
|
stands --section-pane-gap in from the end edge and from under the top bar. */
|
|
29
|
-
--section-pane-width:
|
|
29
|
+
--section-pane-width: calc(var(--navigation-drawer-width, 22.5rem) + var(--section-pane-gap));
|
|
30
30
|
--section-pane-gap: 2rem;
|
|
31
|
+
|
|
32
|
+
/* The side panel of a block's fields and the section settings (BottomSheet `panel` in ui.tsx) on large and wider: half the
|
|
33
|
+
space beside the start panel, the content column taking the other half (see below). */
|
|
34
|
+
--side-panel-width: calc((100vw - var(--column-start)) / 2);
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/* In dark mode the score's number in the light colours, which are read on the dark. */
|
|
@@ -438,7 +442,9 @@
|
|
|
438
442
|
far as it must to stay clear of the panels: its start margin is the middle of the window less the start panel, but
|
|
439
443
|
not less than nothing and not more than the space left between the panels less the column. Only when that space is
|
|
440
444
|
smaller than the column does it get narrower. (Measured against the window's width, 100vw, scrollbar and all.) Below
|
|
441
|
-
medium nothing stands beside the content and the column is centred by auto margins.
|
|
445
|
+
medium nothing stands beside the content and the column is centred by auto margins. While the side panel is open (see
|
|
446
|
+
the end of this block) the panel takes the end half of the space beside the start panel and the column the other half:
|
|
447
|
+
--column-width stays the column's most, so on a very wide window it is narrower than its half. */
|
|
442
448
|
:root {
|
|
443
449
|
--column-start: 0px;
|
|
444
450
|
--column-end: 0px;
|
|
@@ -479,6 +485,34 @@
|
|
|
479
485
|
:root:has(.editor-section-pane) .editor-save-bar {
|
|
480
486
|
right: var(--section-pane-width);
|
|
481
487
|
}
|
|
488
|
+
|
|
489
|
+
/* The side panel (.editor-side-panel: BottomSheet `panel`; BlockDialog.tsx sets [data-side-panel] on the root while it is
|
|
490
|
+
open) stands at the window's end edge under the top bar, the full remaining height. It takes the "on this page" area's
|
|
491
|
+
place (that is hidden), and the content, which stays in view, inert behind the modal <dialog>, keeps clear of it. Its
|
|
492
|
+
backdrop is clear: the page is not dimmed. */
|
|
493
|
+
:root[data-side-panel] {
|
|
494
|
+
--column-end: var(--side-panel-width);
|
|
495
|
+
}
|
|
496
|
+
:root[data-side-panel] .editor-section-pane {
|
|
497
|
+
display: none;
|
|
498
|
+
}
|
|
499
|
+
:root[data-side-panel] .editor-content {
|
|
500
|
+
padding-inline-end: var(--side-panel-width);
|
|
501
|
+
}
|
|
502
|
+
:root[data-side-panel] .editor-save-bar {
|
|
503
|
+
right: var(--side-panel-width);
|
|
504
|
+
}
|
|
505
|
+
.editor-side-panel {
|
|
506
|
+
inset: var(--top-bar-height) 0 0 auto;
|
|
507
|
+
width: var(--side-panel-width);
|
|
508
|
+
height: calc(100dvh - var(--top-bar-height));
|
|
509
|
+
}
|
|
510
|
+
.editor-side-panel:dir(rtl) {
|
|
511
|
+
inset: var(--top-bar-height) auto 0 0;
|
|
512
|
+
}
|
|
513
|
+
.editor-side-panel::backdrop {
|
|
514
|
+
background: transparent;
|
|
515
|
+
}
|
|
482
516
|
}
|
|
483
517
|
|
|
484
518
|
/* Like an app: no grey flash when tapping links and buttons on a phone, and no zoom on a double tap.
|
package/src/fields.tsx
CHANGED
|
@@ -509,6 +509,16 @@ function FieldControl({ field, value, onChange }: Props) {
|
|
|
509
509
|
// that depends on one there (inUse).
|
|
510
510
|
const AroundHere = createContext<Around | undefined>(undefined);
|
|
511
511
|
|
|
512
|
+
/** An object field's own fields without its group, whose heading is elsewhere (a section of Appearance › Header: "Action
|
|
513
|
+
* button"), at the object's path, so a wrong one is marked as it is in the group (WrongField). */
|
|
514
|
+
export function ObjectFields({ field, value, onChange }: Props) {
|
|
515
|
+
return (
|
|
516
|
+
<PathHere.Provider value={joined(useContext(PathHere), field.name)}>
|
|
517
|
+
<FieldList fields={field.fields!} value={(value ?? {}) as Record<string, unknown>} onChange={onChange} />
|
|
518
|
+
</PathHere.Provider>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
512
522
|
// The fields of an object, in order.
|
|
513
523
|
export function FieldList({
|
|
514
524
|
fields,
|
|
@@ -762,18 +772,21 @@ const capitalFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
762
772
|
// ListItem), and the items under one (its list field, the submenu) indented along a line, shown with
|
|
763
773
|
// the arrow at the right. An item's text and page are changed in a sheet from the bottom,
|
|
764
774
|
// opened with a tap on it or the pencil, and saved with "Gem"; the sheet also removes it. A new item
|
|
765
|
-
// (+ in the top bar, "Tilføj punkt" under a submenu) opens its sheet at once; "Annuller"
|
|
766
|
-
// away again. The arrows move an item up and down.
|
|
775
|
+
// (+ in the top bar, "Tilføj punkt" under a submenu, and under the menu with `add`) opens its sheet at once; "Annuller"
|
|
776
|
+
// there takes it away again. The arrows move an item up and down.
|
|
767
777
|
export function MenuList({
|
|
768
778
|
field,
|
|
769
779
|
value,
|
|
770
780
|
onChange,
|
|
771
781
|
depth = 0,
|
|
782
|
+
add = false,
|
|
772
783
|
}: {
|
|
773
784
|
field: Field;
|
|
774
785
|
value: unknown[];
|
|
775
786
|
onChange: (v: unknown) => void;
|
|
776
787
|
depth?: number;
|
|
788
|
+
/** "Tilføj …" under the list too, where the top bar has no "+" for it (two menus on one screen: Appearance › Header). */
|
|
789
|
+
add?: boolean;
|
|
777
790
|
}) {
|
|
778
791
|
const [open, setOpen] = useState<number | null>(null);
|
|
779
792
|
// The item whose sheet is open, its fields as they are being changed (and as they were, written as
|
|
@@ -884,23 +897,20 @@ export function MenuList({
|
|
|
884
897
|
onChange={(v) => onChange(value.map((x, j) => (j === i ? { ...(x as Item), [sub.name]: v } : x)))}
|
|
885
898
|
depth={depth + 1}
|
|
886
899
|
/>
|
|
887
|
-
<
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
icon={Plus}
|
|
891
|
-
className="mt-1 ml-5"
|
|
900
|
+
<AddItem
|
|
901
|
+
field={sub}
|
|
902
|
+
sub
|
|
892
903
|
onClick={() =>
|
|
893
904
|
onChange(value.map((x, j) => (j === i ? { ...(x as Item), [sub.name]: [...children, newItem(sub)] } : x)))
|
|
894
905
|
}
|
|
895
|
-
|
|
896
|
-
{t.forms.addOne(sub.singular ?? t.forms.item)}
|
|
897
|
-
</Button>
|
|
906
|
+
/>
|
|
898
907
|
</div>
|
|
899
908
|
)}
|
|
900
909
|
</ListItem>
|
|
901
910
|
);
|
|
902
911
|
})}
|
|
903
912
|
</ul>
|
|
913
|
+
{add && <AddItem field={field} onClick={() => onChange([...value, newItem(field)])} />}
|
|
904
914
|
<BottomSheet
|
|
905
915
|
ref={sheetRef}
|
|
906
916
|
aria-label={editing ? nameOf(editing.value) || capitalFirst(singular) : undefined}
|
|
@@ -940,3 +950,12 @@ export function MenuList({
|
|
|
940
950
|
</div>
|
|
941
951
|
);
|
|
942
952
|
}
|
|
953
|
+
|
|
954
|
+
// "Tilføj …" (an item to a list of the menu): under a submenu (`sub`, indented as its items), or under the menu.
|
|
955
|
+
function AddItem({ field, sub = false, onClick }: { field: Field; sub?: boolean; onClick: () => void }) {
|
|
956
|
+
return (
|
|
957
|
+
<Button kind="text" size="small" icon={Plus} className={sub ? 'mt-1 ml-5' : 'mt-1'} onClick={onClick}>
|
|
958
|
+
{t.forms.addOne(field.singular ?? t.forms.item)}
|
|
959
|
+
</Button>
|
|
960
|
+
);
|
|
961
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the form (`now`) changes from footer.json as it was saved (`saved`): only what differs is in it, so the rest of the
|
|
3
|
+
* page stays exactly as on the site.
|
|
4
|
+
* @param {FooterValues} saved
|
|
5
|
+
* @param {FooterValues} now
|
|
6
|
+
* @returns {FooterDraft}
|
|
7
|
+
*/
|
|
8
|
+
export function footerDraft(saved: FooterValues, now: FooterValues): FooterDraft;
|
|
9
|
+
/**
|
|
10
|
+
* Puts what the form changes (footerDraft) into the site's page: the texts as they are, and the links as copies of the
|
|
11
|
+
* page's own first link (or, when it has none, drawn with `theme`).
|
|
12
|
+
* @param {Document} doc
|
|
13
|
+
* @param {FooterDraft} draft
|
|
14
|
+
* @param {FooterTheme} theme
|
|
15
|
+
*/
|
|
16
|
+
export function swapFooter(doc: Document, draft: FooterDraft, theme: FooterTheme): void;
|
|
17
|
+
/**
|
|
18
|
+
* footer.json as the form has it.
|
|
19
|
+
*/
|
|
20
|
+
export type FooterValues = {
|
|
21
|
+
text?: string;
|
|
22
|
+
contactHeading?: string;
|
|
23
|
+
linksHeading?: string;
|
|
24
|
+
links?: {
|
|
25
|
+
label?: string;
|
|
26
|
+
href?: string;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* What the form changes in the footer; undefined leaves it as it is on the site.
|
|
31
|
+
*/
|
|
32
|
+
export type FooterDraft = {
|
|
33
|
+
text?: string;
|
|
34
|
+
contactHeading?: string;
|
|
35
|
+
linksHeading?: string;
|
|
36
|
+
links?: {
|
|
37
|
+
label: string;
|
|
38
|
+
href: string;
|
|
39
|
+
}[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* How the theme draws what the page may not have: the class of a link, for the list of a page whose footer has none.
|
|
43
|
+
*/
|
|
44
|
+
export type FooterTheme = {
|
|
45
|
+
linkClass: string;
|
|
46
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The editor's preview of the footer (FooterSettings.tsx): what the footer's form changes (footerDraft), and those changes
|
|
2
|
+
// put into the site's page (swapFooter), in the parts Footer.astro marks with data-editor: the text under the name
|
|
3
|
+
// (footer-text), the two headings (footer-contact-heading, footer-links-heading) and the links (footer-links).
|
|
4
|
+
// Exposes footerDraft and swapFooter. A DOM (the browser's, jsdom in the tests), no React.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* footer.json as the form has it.
|
|
8
|
+
* @typedef {{ text?: string, contactHeading?: string, linksHeading?: string, links?: { label?: string, href?: string }[] }} FooterValues
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the form changes in the footer; undefined leaves it as it is on the site.
|
|
13
|
+
* @typedef {{ text?: string, contactHeading?: string, linksHeading?: string, links?: { label: string, href: string }[] }} FooterDraft
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// A link with only what the footer shows of it.
|
|
17
|
+
/** @param {{ label?: string, href?: string }} link */
|
|
18
|
+
const linkOf = ({ label = '', href = '' }) => ({ label, href });
|
|
19
|
+
|
|
20
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What the form (`now`) changes from footer.json as it was saved (`saved`): only what differs is in it, so the rest of the
|
|
24
|
+
* page stays exactly as on the site.
|
|
25
|
+
* @param {FooterValues} saved
|
|
26
|
+
* @param {FooterValues} now
|
|
27
|
+
* @returns {FooterDraft}
|
|
28
|
+
*/
|
|
29
|
+
export function footerDraft(saved, now) {
|
|
30
|
+
/** @type {FooterDraft} */
|
|
31
|
+
const draft = {};
|
|
32
|
+
for (const name of /** @type {const} */ (['text', 'contactHeading', 'linksHeading'])) {
|
|
33
|
+
if ((now[name] ?? '') !== (saved[name] ?? '')) draft[name] = now[name] ?? '';
|
|
34
|
+
}
|
|
35
|
+
const [links, before] = [(now.links ?? []).map(linkOf), (saved.links ?? []).map(linkOf)];
|
|
36
|
+
if (!same(links, before)) draft.links = links;
|
|
37
|
+
return draft;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How the theme draws what the page may not have: the class of a link, for the list of a page whose footer has none.
|
|
42
|
+
* @typedef {{ linkClass: string }} FooterTheme
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** @param {ParentNode} doc @param {string} name @returns {HTMLElement | null} */
|
|
46
|
+
const part = (doc, name) => doc.querySelector(`[data-editor="${name}"]`);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Puts what the form changes (footerDraft) into the site's page: the texts as they are, and the links as copies of the
|
|
50
|
+
* page's own first link (or, when it has none, drawn with `theme`).
|
|
51
|
+
* @param {Document} doc
|
|
52
|
+
* @param {FooterDraft} draft
|
|
53
|
+
* @param {FooterTheme} theme
|
|
54
|
+
*/
|
|
55
|
+
export function swapFooter(doc, draft, theme) {
|
|
56
|
+
for (const [name, key] of /** @type {const} */ ([
|
|
57
|
+
['footer-text', 'text'],
|
|
58
|
+
['footer-contact-heading', 'contactHeading'],
|
|
59
|
+
['footer-links-heading', 'linksHeading'],
|
|
60
|
+
])) {
|
|
61
|
+
const el = part(doc, name);
|
|
62
|
+
if (el && draft[key] !== undefined) el.textContent = draft[key];
|
|
63
|
+
}
|
|
64
|
+
const list = part(doc, 'footer-links');
|
|
65
|
+
if (!list || !draft.links) return;
|
|
66
|
+
const one = list.firstElementChild ?? null;
|
|
67
|
+
list.replaceChildren(
|
|
68
|
+
...draft.links.map((link) => {
|
|
69
|
+
let row = /** @type {HTMLElement | null} */ (one?.cloneNode(true) ?? null);
|
|
70
|
+
if (!row) {
|
|
71
|
+
row = doc.createElement('li');
|
|
72
|
+
const a = doc.createElement('a');
|
|
73
|
+
a.className = theme.linkClass;
|
|
74
|
+
row.append(a);
|
|
75
|
+
}
|
|
76
|
+
const a = row.querySelector('a');
|
|
77
|
+
if (a) {
|
|
78
|
+
a.textContent = link.label;
|
|
79
|
+
a.setAttribute('href', link.href);
|
|
80
|
+
}
|
|
81
|
+
return row;
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Tests footerPreview.mjs: what the footer's form changes (footerDraft), and those changes put into a page drawn as
|
|
2
|
+
// Footer.astro draws it (swapFooter). jsdom stands in for the browser's DOM.
|
|
3
|
+
// Run: node --test src/footerPreview.test.mjs
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { JSDOM } from 'jsdom';
|
|
7
|
+
import { footerDraft, swapFooter } from './footerPreview.mjs';
|
|
8
|
+
|
|
9
|
+
const PAGE = `<!doctype html><html><body>
|
|
10
|
+
<footer data-editor="footer"><p class="name">Navn</p><p data-editor="footer-text">Gammel tekst</p>
|
|
11
|
+
<p data-editor="footer-contact-heading">Kontakt</p>
|
|
12
|
+
<p data-editor="footer-links-heading">Links</p>
|
|
13
|
+
<ul data-editor="footer-links"><li class="row"><a href="/a/" class="item">A</a></li><li class="row"><a href="/b/" class="item">B</a></li></ul>
|
|
14
|
+
</footer></body></html>`;
|
|
15
|
+
|
|
16
|
+
const THEME = { linkClass: 'item' };
|
|
17
|
+
const page = () => new JSDOM(PAGE).window.document;
|
|
18
|
+
const part = (doc, name) => doc.querySelector(`[data-editor="${name}"]`);
|
|
19
|
+
|
|
20
|
+
const SAVED = {
|
|
21
|
+
text: 'Gammel tekst',
|
|
22
|
+
contactHeading: 'Kontakt',
|
|
23
|
+
linksHeading: 'Links',
|
|
24
|
+
links: [
|
|
25
|
+
{ label: 'A', href: '/a/' },
|
|
26
|
+
{ label: 'B', href: '/b/' },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
test('footerDraft: nothing changed is an empty draft', () => {
|
|
31
|
+
assert.deepEqual(footerDraft(SAVED, structuredClone(SAVED)), {});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('footerDraft: only the texts that differ', () => {
|
|
35
|
+
assert.deepEqual(footerDraft(SAVED, { ...SAVED, text: 'Ny', linksHeading: 'Praktisk' }), { text: 'Ny', linksHeading: 'Praktisk' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('footerDraft: the links with only label and href', () => {
|
|
39
|
+
const now = { ...SAVED, links: [{ label: 'C', href: '/c/', extra: 1 }] };
|
|
40
|
+
assert.deepEqual(footerDraft(SAVED, now), { links: [{ label: 'C', href: '/c/' }] });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('footerDraft: a file the site has not got is empty', () => {
|
|
44
|
+
assert.deepEqual(footerDraft({}, {}), {});
|
|
45
|
+
assert.deepEqual(footerDraft({}, { text: 'Hej' }), { text: 'Hej' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('swapFooter: an empty draft leaves the page as it is', () => {
|
|
49
|
+
const doc = page();
|
|
50
|
+
const before = doc.body.innerHTML;
|
|
51
|
+
swapFooter(doc, {}, THEME);
|
|
52
|
+
assert.equal(doc.body.innerHTML, before);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('swapFooter: the text and the two headings are redrawn', () => {
|
|
56
|
+
const doc = page();
|
|
57
|
+
swapFooter(doc, { text: 'Ny tekst', contactHeading: 'Ring', linksHeading: 'Praktisk' }, THEME);
|
|
58
|
+
assert.equal(part(doc, 'footer-text').textContent, 'Ny tekst');
|
|
59
|
+
assert.equal(part(doc, 'footer-contact-heading').textContent, 'Ring');
|
|
60
|
+
assert.equal(part(doc, 'footer-links-heading').textContent, 'Praktisk');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('swapFooter: the links are copies of the page own', () => {
|
|
64
|
+
const doc = page();
|
|
65
|
+
const links = [
|
|
66
|
+
{ label: 'X', href: '/x/' },
|
|
67
|
+
{ label: 'Y', href: '/y/' },
|
|
68
|
+
{ label: 'Z', href: '/z/' },
|
|
69
|
+
];
|
|
70
|
+
swapFooter(doc, { links }, THEME);
|
|
71
|
+
const rows = [...part(doc, 'footer-links').children];
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
rows.map((li) => [li.className, li.querySelector('a').textContent, li.querySelector('a').getAttribute('href')]),
|
|
74
|
+
[
|
|
75
|
+
['row', 'X', '/x/'],
|
|
76
|
+
['row', 'Y', '/y/'],
|
|
77
|
+
['row', 'Z', '/z/'],
|
|
78
|
+
],
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('swapFooter: links with none on the page are drawn with the theme class', () => {
|
|
83
|
+
const doc = page();
|
|
84
|
+
part(doc, 'footer-links').replaceChildren();
|
|
85
|
+
swapFooter(doc, { links: [{ label: 'X', href: '/x/' }] }, THEME);
|
|
86
|
+
assert.equal(part(doc, 'footer-links').innerHTML, '<li><a class="item" href="/x/">X</a></li>');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('swapFooter: no links leaves an empty list', () => {
|
|
90
|
+
const doc = page();
|
|
91
|
+
swapFooter(doc, { links: [] }, THEME);
|
|
92
|
+
assert.equal(part(doc, 'footer-links').children.length, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('swapFooter: a page without the marked parts is left alone', () => {
|
|
96
|
+
const doc = new JSDOM('<body><footer data-editor="footer"><slot></slot></footer></body>').window.document;
|
|
97
|
+
const before = doc.body.innerHTML;
|
|
98
|
+
swapFooter(doc, { text: 'x', links: [{ label: 'a', href: '/' }] }, THEME);
|
|
99
|
+
assert.equal(doc.body.innerHTML, before);
|
|
100
|
+
});
|