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