@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,94 @@
|
|
|
1
|
+
// Several single data files edited on one screen (Appearance › Header: the two menus and header.json): each one's values
|
|
2
|
+
// in the form and as they were saved, a change to them, whether any is changed, the field that keeps one from being saved,
|
|
3
|
+
// and "Gem ændringer", which saves each changed file in its own commit, as SideColumn.tsx saves its file. Exposes
|
|
4
|
+
// useDataFiles. Uses dataFileValues.ts, check.ts (problemAt), fields.tsx (useWrongField) and Env (App.tsx).
|
|
5
|
+
import { useMemo, useState } from 'react';
|
|
6
|
+
import type { DataFile, FilesCollection } from './schema';
|
|
7
|
+
import { ConflictError, type RepoFile } from './github';
|
|
8
|
+
import { problemAt } from './check';
|
|
9
|
+
import { useWrongField, type Wrong } from './fields';
|
|
10
|
+
import { dataFileIn, dataOf, fieldsOf, valuesOf } from './dataFileValues';
|
|
11
|
+
import { asWritten } from './site';
|
|
12
|
+
import { changesText, lower, savedText } from './deploy';
|
|
13
|
+
import type { Env } from './App';
|
|
14
|
+
import { siteT, t } from './i18n';
|
|
15
|
+
|
|
16
|
+
/** A file on the screen: its collection and the file. */
|
|
17
|
+
export type DataFilePart = { collection: FilesCollection; dataFile: DataFile };
|
|
18
|
+
|
|
19
|
+
type Values = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
/** `onWrong`: a file's field keeps it from being saved (its place in `parts`, the field's path), before it is marked: so
|
|
22
|
+
* the screen can open what the field is in. */
|
|
23
|
+
export function useDataFiles(env: Env, parts: DataFilePart[], onWrong?: (i: number, path: string) => void) {
|
|
24
|
+
const { notify } = env;
|
|
25
|
+
// The files as last saved (or loaded); an optional one the site has not got yet is empty (dataFileIn).
|
|
26
|
+
const [bases, setBases] = useState<RepoFile[]>(() =>
|
|
27
|
+
parts.map((p) => dataFileIn(env.files, p.dataFile) ?? { path: p.dataFile.file, oid: '', text: '{}\n' }),
|
|
28
|
+
);
|
|
29
|
+
const read = (i: number, text: string) => valuesOf(text, fieldsOf(parts[i].dataFile), parts[i].dataFile, parts[i].collection, env.files);
|
|
30
|
+
const initial = useMemo(() => bases.map((b, i) => read(i, b.text)), [bases]);
|
|
31
|
+
const [values, setValues] = useState<Values[]>(initial);
|
|
32
|
+
const [saving, setSaving] = useState(false);
|
|
33
|
+
// The field that keeps a file from being saved, marked in that file's fields only (the menus have the same paths).
|
|
34
|
+
const wrongField = useWrongField((text) => notify({ text, error: true }));
|
|
35
|
+
const [wrongIn, setWrongIn] = useState(-1);
|
|
36
|
+
const changed = values.flatMap((v, i) => (JSON.stringify(v) !== JSON.stringify(initial[i]) ? [i] : []));
|
|
37
|
+
|
|
38
|
+
async function save() {
|
|
39
|
+
// Every changed file is right before any is saved.
|
|
40
|
+
for (const i of changed) {
|
|
41
|
+
const problem = problemAt(fieldsOf(parts[i].dataFile), values[i]);
|
|
42
|
+
if (problem) {
|
|
43
|
+
setWrongIn(i);
|
|
44
|
+
onWrong?.(i, problem.path);
|
|
45
|
+
wrongField.stop(problem);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const texts = changed
|
|
50
|
+
.map((i) => ({ i, text: `${JSON.stringify(dataOf(fieldsOf(parts[i].dataFile), values[i], parts[i].dataFile), null, 2)}\n` }))
|
|
51
|
+
.filter(({ i, text }) => text !== bases[i].text);
|
|
52
|
+
if (!texts.length) {
|
|
53
|
+
notify({ text: t.forms.nothingToSave });
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
setSaving(true);
|
|
57
|
+
notify({ text: t.common.saving });
|
|
58
|
+
const done: string[] = [];
|
|
59
|
+
try {
|
|
60
|
+
for (const { i, text } of texts) {
|
|
61
|
+
const { dataFile } = parts[i];
|
|
62
|
+
const path = bases[i].path;
|
|
63
|
+
const change = t.app.changes.edited(lower(dataFile.label));
|
|
64
|
+
const message = siteT.forms.commit.edit(asWritten(dataFile).label.toLowerCase());
|
|
65
|
+
const oids = await env.commit(message, [{ path, content: text, oid: bases[i].oid || null }], change);
|
|
66
|
+
const saved = { path, oid: oids[path], text };
|
|
67
|
+
setBases((all) => all.map((b, j) => (j === i ? saved : b)));
|
|
68
|
+
setValues((all) => all.map((v, j) => (j === i ? read(i, text) : v)));
|
|
69
|
+
env.onSaved(saved);
|
|
70
|
+
done.push(change);
|
|
71
|
+
}
|
|
72
|
+
notify({ text: savedText(changesText(done)), kind: 'saved' });
|
|
73
|
+
return true;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
notify({ text: e instanceof ConflictError ? e.message : t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
|
|
76
|
+
return false;
|
|
77
|
+
} finally {
|
|
78
|
+
setSaving(false);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
/** Each file's values in the form, and as they were saved (in the same form, for what the preview changes). */
|
|
84
|
+
values,
|
|
85
|
+
initial,
|
|
86
|
+
/** Sets one file's values (by its place in `parts`). */
|
|
87
|
+
set: (i: number, v: Values) => setValues((all) => all.map((x, j) => (j === i ? v : x))),
|
|
88
|
+
dirty: changed.length > 0,
|
|
89
|
+
saving,
|
|
90
|
+
save,
|
|
91
|
+
/** The field marked in a file's fields (WrongField), or null. */
|
|
92
|
+
wrong: (i: number): Wrong | null => (wrongIn === i ? wrongField.field : null),
|
|
93
|
+
};
|
|
94
|
+
}
|
package/starter/package.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@material-symbols/svg-400": "0.48.2",
|
|
33
33
|
"@material/material-color-utilities": "^0.3.0",
|
|
34
34
|
"@milkdown/kit": "^7.22.3",
|
|
35
|
-
"@morphemecms/cms": "0.
|
|
35
|
+
"@morphemecms/cms": "0.9.0",
|
|
36
36
|
"@phosphor-icons/core": "^2.1.1",
|
|
37
37
|
"@phosphor-icons/react": "^2.1.10",
|
|
38
38
|
"@tailwindcss/typography": "^0.5.20",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
// The footer in Material: a surface container with extra-large corners at the top, the business as a headline, how to
|
|
3
|
-
// reach it as a list with leading icons, the practical links (footer.json,
|
|
3
|
+
// reach it as a list with leading icons, the practical links (footer.json, Appearance › Footer in the editor) or
|
|
4
4
|
// what a page puts in the slot `footer-column` instead (a blog post's related posts), and at the bottom the cookie
|
|
5
|
-
// settings and light or dark as text buttons.
|
|
5
|
+
// settings and light or dark as text buttons. data-editor marks the footer and the parts its form changes (the editor's
|
|
6
|
+
// preview of Appearance › Footer, src/footerPreview.mjs).
|
|
6
7
|
import Icon from '@theme/components/Icon.astro';
|
|
7
8
|
import Container from '@theme/components/Container.astro';
|
|
8
9
|
import LogoMark from '@theme/components/LogoMark.astro';
|
|
@@ -20,15 +21,19 @@ const contactLinks = [
|
|
|
20
21
|
];
|
|
21
22
|
---
|
|
22
23
|
|
|
23
|
-
<footer class="md-footer">
|
|
24
|
+
<footer class="md-footer" data-editor="footer">
|
|
24
25
|
<Container as="div" class="grid gap-10 py-12 expanded:grid-cols-12 medium:py-16">
|
|
25
26
|
<div class="expanded:col-span-5">
|
|
26
27
|
<LogoMark class="block h-12 w-11 bg-accent" />
|
|
27
28
|
<p class="md-footer-name">{site.name}</p>
|
|
28
|
-
<p class="md-footer-text"
|
|
29
|
+
<p class="md-footer-text" data-editor="footer-text">
|
|
30
|
+
{footer.text}
|
|
31
|
+
</p>
|
|
29
32
|
</div>
|
|
30
33
|
<div class="expanded:col-span-4">
|
|
31
|
-
<p class="md-footer-heading"
|
|
34
|
+
<p class="md-footer-heading" data-editor="footer-contact-heading">
|
|
35
|
+
{footer.contactHeading}
|
|
36
|
+
</p>
|
|
32
37
|
<ul class="md-footer-list">
|
|
33
38
|
{contactLinks.map((c) => (
|
|
34
39
|
<li>
|
|
@@ -49,8 +54,10 @@ const contactLinks = [
|
|
|
49
54
|
</div>
|
|
50
55
|
<div class="md-footer-column expanded:col-span-3">
|
|
51
56
|
<slot name="footer-column">
|
|
52
|
-
<p class="md-footer-heading"
|
|
53
|
-
|
|
57
|
+
<p class="md-footer-heading" data-editor="footer-links-heading">
|
|
58
|
+
{footer.linksHeading}
|
|
59
|
+
</p>
|
|
60
|
+
<ul class="md-footer-list" data-editor="footer-links">
|
|
54
61
|
{footer.links.map((l) => (
|
|
55
62
|
<li>
|
|
56
63
|
<a href={l.href} class="md-footer-item">
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
// turned off. Under it the bar of "Mobilmenu" (MobileNav.astro), when it is on. Under it, "Book en tid"
|
|
7
7
|
// as an extended FAB at the bottom right of the screen (ui/Fab.tsx, m3.material.io/components/extended-fab), which shows only its
|
|
8
8
|
// icon while the page scrolls down. Not on the booking page and the thank-you page; on a phone with a bar at the
|
|
9
|
-
// bottom of the screen, the bar's first button is the FAB instead (global.css).
|
|
9
|
+
// bottom of the screen, the bar's first button is the FAB instead (global.css). header.json (optional, site/header.mjs) can put a
|
|
10
|
+
// button in place of the phone icon (shown from the medium window size up), and the menu button at the end; data-editor marks the
|
|
11
|
+
// header, the menu button, the menu's items (header-nav) and the button's place (header-cta) for the editor's preview; their
|
|
12
|
+
// classes are in headerParts.ts, which the preview shares.
|
|
10
13
|
import Icon from '@theme/components/Icon.astro';
|
|
11
14
|
import MobileMenu from '@theme/components/MobileMenu.tsx';
|
|
12
15
|
import Logo from '@theme/components/Logo.astro';
|
|
@@ -15,37 +18,43 @@ import MobileNav from '@theme/components/MobileNav.astro';
|
|
|
15
18
|
import { topAppBarClass } from '@theme/ui/TopAppBar';
|
|
16
19
|
import { menuClass, menuItemClass } from '@theme/ui/Menu';
|
|
17
20
|
import { extendedFabClass } from '@theme/ui/Fab';
|
|
18
|
-
import
|
|
21
|
+
import Buttons from '@theme/blocks/buttons/Buttons';
|
|
22
|
+
import { headerCtaClass, headerEdge, headerMenuClass } from '@theme/components/headerParts';
|
|
23
|
+
import { loadButtons } from '@morphemecms/cms/src/standard/buttons/load';
|
|
24
|
+
import { site, nav, navShown, header } from '@morphemecms/cms/site/site';
|
|
19
25
|
import { siteT } from '@morphemecms/cms/src/i18n';
|
|
20
26
|
|
|
21
27
|
const current = Astro.url.pathname;
|
|
22
28
|
const isActive = (href: string) => current === href || (href !== '/' && current.startsWith(href));
|
|
29
|
+
// The header's own button (header.json), in place of the phone icon from the medium window size up; none when it is off.
|
|
30
|
+
const button = header.button ? (await loadButtons([{ ...header.button, kind: 'filled' }]))[0] : undefined;
|
|
31
|
+
const menuEnd = header.menuSide === 'end';
|
|
32
|
+
const menuProps = {
|
|
33
|
+
items: nav,
|
|
34
|
+
current,
|
|
35
|
+
bookingPath: site.bookingPath,
|
|
36
|
+
phone: site.phone,
|
|
37
|
+
phoneHref: site.phoneHref,
|
|
38
|
+
name: site.name,
|
|
39
|
+
role: site.role,
|
|
40
|
+
book: site.labels.book,
|
|
41
|
+
call: site.labels.call,
|
|
42
|
+
texts: {
|
|
43
|
+
open: siteT.theme.openMenu,
|
|
44
|
+
close: siteT.theme.closeMenu,
|
|
45
|
+
menu: siteT.theme.mainMenu,
|
|
46
|
+
drawer: siteT.theme.mobileMenu,
|
|
47
|
+
subpages: nav.map((item) => siteT.theme.subpagesOf(item.label)),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
23
50
|
const fab = !current.startsWith(site.bookingPath) && current !== '/tak/';
|
|
24
51
|
---
|
|
25
52
|
|
|
26
|
-
<header data-header class:list={[topAppBarClass, 'group/header sticky top-0 z-[43]']}>
|
|
53
|
+
<header data-header data-editor="header" class:list={[topAppBarClass, 'group/header sticky top-0 z-[43]']}>
|
|
27
54
|
<Container as="div" data-header-row class="flex h-16 items-center gap-1">
|
|
28
|
-
{navShown && (
|
|
29
|
-
<div
|
|
30
|
-
<MobileMenu
|
|
31
|
-
client:interact
|
|
32
|
-
items={nav}
|
|
33
|
-
current={current}
|
|
34
|
-
bookingPath={site.bookingPath}
|
|
35
|
-
phone={site.phone}
|
|
36
|
-
phoneHref={site.phoneHref}
|
|
37
|
-
name={site.name}
|
|
38
|
-
role={site.role}
|
|
39
|
-
book={site.labels.book}
|
|
40
|
-
call={site.labels.call}
|
|
41
|
-
texts={{
|
|
42
|
-
open: siteT.theme.openMenu,
|
|
43
|
-
close: siteT.theme.closeMenu,
|
|
44
|
-
menu: siteT.theme.mainMenu,
|
|
45
|
-
drawer: siteT.theme.mobileMenu,
|
|
46
|
-
subpages: nav.map((item) => siteT.theme.subpagesOf(item.label)),
|
|
47
|
-
}}
|
|
48
|
-
/>
|
|
55
|
+
{navShown && !menuEnd && (
|
|
56
|
+
<div data-editor="header-menu" class={headerMenuClass('start')}>
|
|
57
|
+
<MobileMenu client:interact {...menuProps} />
|
|
49
58
|
</div>
|
|
50
59
|
)}
|
|
51
60
|
<a href="/" class="md-top-app-bar-title mr-auto large:shrink-0 large:whitespace-nowrap">
|
|
@@ -53,7 +62,11 @@ const fab = !current.startsWith(site.bookingPath) && current !== '/tak/';
|
|
|
53
62
|
</a>
|
|
54
63
|
|
|
55
64
|
{navShown && (
|
|
56
|
-
<nav
|
|
65
|
+
<nav
|
|
66
|
+
aria-label={siteT.theme.mainMenu}
|
|
67
|
+
data-editor="header-nav"
|
|
68
|
+
class="hidden shrink-0 large:block large:group-data-crowded/header:hidden"
|
|
69
|
+
>
|
|
57
70
|
<ul class="flex items-center gap-1">
|
|
58
71
|
{nav.map((item, i) => (
|
|
59
72
|
<li class="group relative flex items-center" data-nav-item>
|
|
@@ -102,14 +115,26 @@ const fab = !current.startsWith(site.bookingPath) && current !== '/tak/';
|
|
|
102
115
|
</nav>
|
|
103
116
|
)}
|
|
104
117
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
{button ? (
|
|
119
|
+
<div data-editor="header-cta" class={headerCtaClass}>
|
|
120
|
+
<Buttons buttons={[button]} />
|
|
121
|
+
</div>
|
|
122
|
+
) : (
|
|
123
|
+
<a
|
|
124
|
+
href={site.phoneHref}
|
|
125
|
+
data-editor="header-cta"
|
|
126
|
+
class:list={['icon-button icon-button-standard', headerEdge.end]}
|
|
127
|
+
aria-label={`${site.labels.call} ${site.phone}`}
|
|
128
|
+
title={site.phone}
|
|
129
|
+
>
|
|
130
|
+
<Icon name="phone" size={24} />
|
|
131
|
+
</a>
|
|
132
|
+
)}
|
|
133
|
+
{navShown && menuEnd && (
|
|
134
|
+
<div data-editor="header-menu" class={headerMenuClass('end')}>
|
|
135
|
+
<MobileMenu client:interact {...menuProps} />
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
113
138
|
</Container>
|
|
114
139
|
<MobileNav />
|
|
115
140
|
</header>
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// top with it: its links on one line as scrollable tabs (m3.material.io/components/tabs), on a phone and a tablet (where
|
|
4
4
|
// the menu is behind the menu button), to be swiped sideways when they do not fit, with the open page's chosen (its
|
|
5
5
|
// label in its own colour) and scrolled into view: the theme's tabs (ui/Tabs.tsx, .tab in styles/ui.css). Not there when
|
|
6
|
-
// it is turned off or has no links.
|
|
7
|
-
import {
|
|
6
|
+
// it is turned off or has no links. data-editor marks it for the editor's preview of the header (its classes: headerParts.ts).
|
|
7
|
+
import { tabClass } from '@theme/ui/Tabs';
|
|
8
|
+
import { mobileNavClass, mobileNavListClass } from '@theme/components/headerParts';
|
|
8
9
|
import { mobileNav } from '@morphemecms/cms/site/site';
|
|
9
10
|
import { siteT } from '@morphemecms/cms/src/i18n';
|
|
10
11
|
|
|
@@ -13,8 +14,8 @@ const isActive = (href: string) => current === href || (href !== '/' && current.
|
|
|
13
14
|
---
|
|
14
15
|
|
|
15
16
|
{mobileNav.length > 0 && (
|
|
16
|
-
<nav aria-label={siteT.theme.mobileMenu} data-mobile-nav
|
|
17
|
-
<ul class
|
|
17
|
+
<nav aria-label={siteT.theme.mobileMenu} data-mobile-nav data-editor="mobile-nav" class={mobileNavClass}>
|
|
18
|
+
<ul class={mobileNavListClass}>
|
|
18
19
|
{mobileNav.map((item) => (
|
|
19
20
|
<li>
|
|
20
21
|
<a href={item.href} class={tabClass} aria-current={isActive(item.href) ? 'page' : undefined}>
|
|
@@ -18,7 +18,7 @@ const { posts } = Astro.props;
|
|
|
18
18
|
<h2 id="related-posts" class="type-headline-small">
|
|
19
19
|
{siteT.theme.relatedPosts}
|
|
20
20
|
</h2>
|
|
21
|
-
<ul class="mt-6 grid gap-
|
|
21
|
+
<ul class="mt-6 grid gap-(--card-gap) medium:grid-cols-3">
|
|
22
22
|
{posts.map((post) => (
|
|
23
23
|
<li>
|
|
24
24
|
<PostCard post={post} />
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The classes of the header's parts that its settings move or put in (Header.astro, MobileNav.astro), shared with the
|
|
2
|
+
// editor's preview of the header (src/headerTheme.tsx), so both draw them the same.
|
|
3
|
+
// Exposes headerEdge, headerMenuClass, headerCtaClass, mobileNavClass and mobileNavListClass. Uses ui/Tabs.tsx.
|
|
4
|
+
import { tabsClass } from '@theme/ui/Tabs';
|
|
5
|
+
|
|
6
|
+
/** The place of a part at the start of the row (the menu button) and at its end (the phone, or the menu button). */
|
|
7
|
+
export const headerEdge = { start: '-ml-2 mr-1', end: '-mr-2 ml-1' } as const;
|
|
8
|
+
|
|
9
|
+
/** The menu button's box at either side: shown where the menu's items are not (below large, or when they do not fit). */
|
|
10
|
+
export const headerMenuClass = (side: 'start' | 'end') => `${headerEdge[side]} large:hidden large:group-data-crowded/header:block`;
|
|
11
|
+
|
|
12
|
+
/** The header's own button's box (header.json): only from the medium window size up. */
|
|
13
|
+
export const headerCtaClass = 'hidden shrink-0 medium:block';
|
|
14
|
+
|
|
15
|
+
/** The bar of "Mobilmenu" under the header, where the menu's items are not, and its row of tabs. */
|
|
16
|
+
export const mobileNavClass = 'large:hidden large:group-data-crowded/header:block';
|
|
17
|
+
export const mobileNavListClass = `${tabsClass} relative mx-auto max-w-6xl px-1 medium:px-4`;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
// A page (the standard theme's): back link, title and the text with its blocks (its pictures in it), in one column
|
|
3
|
-
// the block src/data/side.json chooses in a column beside it
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// A page (the standard theme's): back link, title and the text with its blocks (its pictures in it), in one column, or
|
|
3
|
+
// with the block src/data/side.json chooses in a column beside it (sideBlock) when its template is a sidebar one
|
|
4
|
+
// (`template`, lib/page-template.mjs: the theme's "templates"), and the booking prompt across the page when the text ends
|
|
5
|
+
// with it. The wrapper carries data-template, so a theme can style a template; `full` is drawn like `single` here.
|
|
6
|
+
// Width: with a column beside it the text is 66ch wide, blocks and all; without one (single, full) the text column is the
|
|
7
|
+
// container's width, its blocks (not-prose) span it, and its other parts (paragraphs, headings) keep 66ch. Its first
|
|
8
|
+
// paragraph is the lead (type-lead, ui.css: the --type-lead-* tokens), muted and 52ch wide.
|
|
7
9
|
// data-editor marks what the editor's preview fills in with what is in its form ("Forhåndsvis",
|
|
8
10
|
// packages/editor/src/pagePreview.tsx): the heading, the text, and the booking prompt at the end.
|
|
9
11
|
import Base from '@theme/layouts/Base.astro';
|
|
@@ -15,6 +17,8 @@ import { getPages, pageHeading, pageHeadingHtml, parentPage, stateOf, withoutH1,
|
|
|
15
17
|
import { blockTexts, nav } from '@morphemecms/cms/site/site';
|
|
16
18
|
import { firstPicture, picturesOf } from '@morphemecms/cms/site/images.mjs';
|
|
17
19
|
import { splitBlocks } from '@morphemecms/cms/lib/blocks.mjs';
|
|
20
|
+
import { hasSidebar, templateOf } from '@morphemecms/cms/lib/page-template.mjs';
|
|
21
|
+
import { offeredTemplates } from '@morphemecms/cms/site/templates';
|
|
18
22
|
import { markdownHtml } from '@morphemecms/cms/lib/inline';
|
|
19
23
|
import Container from '@theme/components/Container.astro';
|
|
20
24
|
import RichText from '@theme/components/RichText.astro';
|
|
@@ -36,7 +40,8 @@ const parent = parentEntry
|
|
|
36
40
|
: parentNav
|
|
37
41
|
? { href: parentNav.href, title: parentNav.label }
|
|
38
42
|
: undefined;
|
|
39
|
-
const { description, path
|
|
43
|
+
const { description, path } = page.data;
|
|
44
|
+
const template = templateOf(page.data, offeredTemplates);
|
|
40
45
|
const heading = pageHeading(page);
|
|
41
46
|
// With its colours, when a word in it has one.
|
|
42
47
|
const headingHtml = pageHeadingHtml(page);
|
|
@@ -64,7 +69,7 @@ const preview = isPreview(path);
|
|
|
64
69
|
const current = livePath(path);
|
|
65
70
|
const review = (page.data.review ?? '').trim();
|
|
66
71
|
// The block beside the text (sideBlock): when its type's load gives a view; else the page is drawn as without one.
|
|
67
|
-
const sideType = sideBlock(
|
|
72
|
+
const sideType = sideBlock(template);
|
|
68
73
|
const sideText = end ? end.html : html;
|
|
69
74
|
const side =
|
|
70
75
|
sideType &&
|
|
@@ -89,7 +94,7 @@ const side =
|
|
|
89
94
|
crumbs={parent ? [{ name: parent.title, href: parent.href }] : []}
|
|
90
95
|
questions={questions}
|
|
91
96
|
>
|
|
92
|
-
<article>
|
|
97
|
+
<article data-template={template}>
|
|
93
98
|
<Container as="header" class="pt-8 medium:pt-14">
|
|
94
99
|
{parent && (
|
|
95
100
|
<a href={parent.href} class="inline-flex items-center gap-1.5 type-body-medium text-muted hover:text-accent">
|
|
@@ -102,16 +107,15 @@ const side =
|
|
|
102
107
|
</h1>
|
|
103
108
|
</Container>
|
|
104
109
|
|
|
105
|
-
<Container as="div" class
|
|
106
|
-
<div {...(side ? { class: 'page-with-side' } : {})}>
|
|
110
|
+
<Container as="div" class="mt-8 medium:mt-12">
|
|
111
|
+
<div {...(side ? { class: 'page-with-side', 'data-side': template === 'sidebar-left' ? 'left' : 'right' } : {})}>
|
|
107
112
|
<div
|
|
108
113
|
data-editor="text"
|
|
109
|
-
class
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
]}
|
|
114
|
+
class={
|
|
115
|
+
side
|
|
116
|
+
? 'prose max-w-[66ch]'
|
|
117
|
+
: 'prose max-w-none [&>:not(.not-prose)]:max-w-[66ch] [&>p:first-child]:type-lead [&>p:first-child]:text-muted [&>p:first-child]:max-w-[52ch]'
|
|
118
|
+
}
|
|
115
119
|
>
|
|
116
120
|
<RichText html={sideText} path={current} preview={preview} />
|
|
117
121
|
</div>
|
package/theme/styles/base.css
CHANGED
|
@@ -205,12 +205,13 @@
|
|
|
205
205
|
font-weight: var(--type-body-large-weight);
|
|
206
206
|
letter-spacing: var(--type-body-large-tracking);
|
|
207
207
|
}
|
|
208
|
-
/* A blog post's lead: its first paragraph, one step larger than the text (the
|
|
208
|
+
/* A blog post's lead: its first paragraph, one step larger than the text (the lead tokens, ui.css: the title large's
|
|
209
|
+
unless a theme sets them). */
|
|
209
210
|
.prose.post-lead {
|
|
210
|
-
font-size: var(--type-
|
|
211
|
-
line-height: var(--type-
|
|
212
|
-
font-weight: var(--type-
|
|
213
|
-
letter-spacing: var(--type-
|
|
211
|
+
font-size: var(--type-lead-size);
|
|
212
|
+
line-height: var(--type-lead-line-height);
|
|
213
|
+
font-weight: var(--type-lead-weight);
|
|
214
|
+
letter-spacing: var(--type-lead-tracking);
|
|
214
215
|
}
|
|
215
216
|
/* The text right after the lead (no picture between): the space the prose puts between paragraphs (1.25em), not the
|
|
216
217
|
larger space of a text under a picture. Unlayered on purpose: it must beat the mt-10 / medium:mt-14 utilities (a layer). */
|
package/theme/styles/blocks.css
CHANGED
|
@@ -584,11 +584,11 @@
|
|
|
584
584
|
/* "Udtalelser" one after another (testimonials/List.tsx): two columns where there is room. */
|
|
585
585
|
.block-testimonial-list {
|
|
586
586
|
columns: 2 18rem;
|
|
587
|
-
column-gap:
|
|
587
|
+
column-gap: var(--card-gap, 0.5rem);
|
|
588
588
|
}
|
|
589
589
|
.block-testimonial-list > * {
|
|
590
590
|
break-inside: avoid;
|
|
591
|
-
margin-bottom:
|
|
591
|
+
margin-bottom: var(--card-gap, 0.5rem);
|
|
592
592
|
}
|
|
593
593
|
/* Filter chips (the theme's Chip, ui.css) that filter what is under them ("Udtalelser" under hinanden). */
|
|
594
594
|
.block-filters {
|
|
@@ -744,7 +744,7 @@
|
|
|
744
744
|
two side by side from small screens (cards/LinkCards.tsx). */
|
|
745
745
|
.block-cards {
|
|
746
746
|
display: grid;
|
|
747
|
-
gap:
|
|
747
|
+
gap: var(--card-gap, 0.5rem);
|
|
748
748
|
padding: 0;
|
|
749
749
|
list-style: none;
|
|
750
750
|
}
|
|
@@ -770,7 +770,7 @@
|
|
|
770
770
|
/* The picture fills the top of the card, out to its edges. */
|
|
771
771
|
.block-card-picture {
|
|
772
772
|
display: block;
|
|
773
|
-
margin: -1
|
|
773
|
+
margin: calc(-1 * var(--card-padding, 1rem)) calc(-1 * var(--card-padding, 1rem)) 0.25rem;
|
|
774
774
|
overflow: hidden;
|
|
775
775
|
border-radius: calc(var(--card-radius, var(--block-radius)) - 1px) calc(var(--card-radius, var(--block-radius)) - 1px) 0 0;
|
|
776
776
|
background: var(--block-tint);
|
|
@@ -862,7 +862,7 @@
|
|
|
862
862
|
display: grid;
|
|
863
863
|
/* Two side by side where there is room for 17rem each, never more. */
|
|
864
864
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(17rem, calc(50% - 0.5rem))), 1fr));
|
|
865
|
-
gap:
|
|
865
|
+
gap: var(--card-gap, 0.5rem);
|
|
866
866
|
}
|
|
867
867
|
.block-panels.block-in-text {
|
|
868
868
|
margin-block: 2.5rem;
|
|
@@ -1306,7 +1306,7 @@
|
|
|
1306
1306
|
}
|
|
1307
1307
|
.block-price-cards {
|
|
1308
1308
|
display: grid;
|
|
1309
|
-
gap:
|
|
1309
|
+
gap: var(--card-gap, 0.5rem);
|
|
1310
1310
|
}
|
|
1311
1311
|
/* A big price, an elevated card (.card in ui.css). */
|
|
1312
1312
|
.block-price-card {
|
|
@@ -1459,9 +1459,6 @@
|
|
|
1459
1459
|
}
|
|
1460
1460
|
|
|
1461
1461
|
@media (hover: hover) {
|
|
1462
|
-
a.block-card:hover {
|
|
1463
|
-
--card-outlined-line: var(--block-accent);
|
|
1464
|
-
}
|
|
1465
1462
|
a.block-card:hover .block-card-arrow {
|
|
1466
1463
|
translate: 0.25rem 0;
|
|
1467
1464
|
}
|
|
@@ -1536,8 +1533,7 @@
|
|
|
1536
1533
|
font-weight: var(--type-headline-medium-weight);
|
|
1537
1534
|
letter-spacing: var(--type-headline-medium-tracking);
|
|
1538
1535
|
}
|
|
1539
|
-
.block-prices
|
|
1540
|
-
.block-price-cards {
|
|
1536
|
+
.block-prices {
|
|
1541
1537
|
gap: 1.25rem;
|
|
1542
1538
|
}
|
|
1543
1539
|
.block-price-cards {
|
|
@@ -1597,7 +1593,7 @@
|
|
|
1597
1593
|
}
|
|
1598
1594
|
.block-plans-list {
|
|
1599
1595
|
display: grid;
|
|
1600
|
-
gap:
|
|
1596
|
+
gap: var(--card-gap, 0.5rem);
|
|
1601
1597
|
margin: 0;
|
|
1602
1598
|
padding: 0;
|
|
1603
1599
|
list-style: none;
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/* A page with a block beside its text (PageLayout.astro): the text, then the column (on a phone below the text); from medium
|
|
205
|
-
two columns, the
|
|
205
|
+
two columns, the one beside the text staying in view while the text is read; on the left for `sidebar-left`. */
|
|
206
206
|
.page-with-side > .page-side {
|
|
207
207
|
margin-top: 3rem;
|
|
208
208
|
}
|
|
@@ -218,6 +218,13 @@
|
|
|
218
218
|
position: sticky;
|
|
219
219
|
top: 6rem;
|
|
220
220
|
}
|
|
221
|
+
/* Template sidebar-left: the column first, though it comes after the text in the page. */
|
|
222
|
+
.page-with-side[data-side='left'] {
|
|
223
|
+
grid-template-columns: var(--theme-side-width) minmax(0, 1fr);
|
|
224
|
+
}
|
|
225
|
+
.page-with-side[data-side='left'] > .page-side {
|
|
226
|
+
order: -1;
|
|
227
|
+
}
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
/* A page's and a post's title (PageHeader.astro, PageLayout.astro, PostLayout.astro, Home.astro): the display role, by
|
package/theme/styles/global.css
CHANGED
|
@@ -194,6 +194,8 @@
|
|
|
194
194
|
/* Cards (ui.css): 12px corners; outlined on the surface with the outline variant, elevated on a low container with
|
|
195
195
|
the first elevation, filled on the highest container. */
|
|
196
196
|
--card-radius: var(--md-shape-medium);
|
|
197
|
+
/* The room between cards side by side or under each other: 8px (Material 3's at most). */
|
|
198
|
+
--card-gap: 0.5rem;
|
|
197
199
|
--card-filled-surface: var(--md-surface-container-highest);
|
|
198
200
|
--card-outlined-surface: var(--surface);
|
|
199
201
|
--card-outlined-line: var(--line);
|
|
@@ -851,13 +853,12 @@
|
|
|
851
853
|
padding: 1rem 1.25rem;
|
|
852
854
|
}
|
|
853
855
|
|
|
854
|
-
/* "Kort": filled cards, as Material 3's (their outlined cards on the highest container,
|
|
855
|
-
shadow,
|
|
856
|
+
/* "Kort": filled cards, as Material 3's (their outlined cards on the highest container, with no edge and
|
|
857
|
+
no shadow at rest, and the first elevation when pointed at), with the card's state layer (ui.css) when pointed at or
|
|
858
|
+
focused. */
|
|
856
859
|
.block-card {
|
|
857
860
|
border: 0;
|
|
858
861
|
--card-outlined-surface: var(--md-surface-container-highest);
|
|
859
|
-
--card-outlined-hover-shadow: var(--elevation-0);
|
|
860
|
-
transition: background-color var(--motion-effects);
|
|
861
862
|
}
|
|
862
863
|
.block-card-picture {
|
|
863
864
|
border-radius: var(--card-radius) var(--card-radius) var(--md-shape-none) var(--md-shape-none);
|
|
@@ -865,11 +866,6 @@
|
|
|
865
866
|
.block-card-text {
|
|
866
867
|
font-weight: 400;
|
|
867
868
|
}
|
|
868
|
-
@media (hover: hover) {
|
|
869
|
-
a.block-card:hover {
|
|
870
|
-
--card-outlined-surface: color-mix(in srgb, var(--ink) 8%, var(--md-surface-container-highest));
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
869
|
|
|
874
870
|
/* Panels and the cards of testimonials and prices: outlined, filled and elevated cards (their variables above). A
|
|
875
871
|
list in a panel reaches out to its sides, so its items' text is in line with the panel's. */
|