@morphemecms/cms 0.7.1 → 0.8.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/defaults/collections.ts +56 -0
  3. package/defaults/company.ts +13 -4
  4. package/defaults/texts.ts +17 -0
  5. package/icons/lucide/LICENSE-phosphor +21 -0
  6. package/icons/lucide/facebook-logo.svg +1 -0
  7. package/icons/lucide/linkedin-logo.svg +1 -0
  8. package/icons/lucide/pack.json +16 -1
  9. package/icons/lucide/pinterest-logo.svg +1 -0
  10. package/icons/lucide/x-logo.svg +1 -0
  11. package/icons/material-symbols/LICENSE-phosphor +21 -0
  12. package/icons/material-symbols/facebook-logo.svg +1 -0
  13. package/icons/material-symbols/linkedin-logo.svg +1 -0
  14. package/icons/material-symbols/pack.json +15 -0
  15. package/icons/material-symbols/pinterest-logo.svg +1 -0
  16. package/icons/material-symbols/x-logo.svg +1 -0
  17. package/icons/phosphor/facebook-logo.svg +1 -0
  18. package/icons/phosphor/linkedin-logo.svg +1 -0
  19. package/icons/phosphor/pinterest-logo.svg +1 -0
  20. package/icons/phosphor/x-logo.svg +1 -0
  21. package/lib/lead.d.mts +5 -0
  22. package/lib/lead.mjs +10 -0
  23. package/lib/lead.test.mjs +65 -0
  24. package/package.json +2 -1
  25. package/scripts/icon-packs.mjs +51 -13
  26. package/scripts/material-fallback.test.mjs +49 -0
  27. package/site/icons.d.mts +13 -0
  28. package/site/icons.mjs +25 -3
  29. package/site/schema.ts +2 -0
  30. package/site/site.ts +9 -2
  31. package/site/social.d.mts +37 -0
  32. package/site/social.mjs +92 -0
  33. package/site/social.test.mjs +80 -0
  34. package/site/theme.d.mts +2 -0
  35. package/site/theme.mjs +24 -10
  36. package/site/themeImport.test.mjs +40 -0
  37. package/src/EntryForm.tsx +21 -58
  38. package/src/FormFrame.tsx +5 -5
  39. package/src/Parts.tsx +8 -30
  40. package/src/Screen.tsx +12 -5
  41. package/src/editor.css +70 -5
  42. package/src/i18n/da-DK/parts.ts +1 -1
  43. package/src/i18n/da-DK/theme.ts +4 -0
  44. package/src/i18n/en-US/parts.ts +1 -1
  45. package/src/i18n/en-US/theme.ts +4 -0
  46. package/src/icon.tsx +1 -1
  47. package/src/pagePreview.tsx +21 -1
  48. package/src/postLead.d.mts +7 -0
  49. package/src/postLead.mjs +9 -0
  50. package/src/postLead.test.mjs +28 -0
  51. package/src/ui.tsx +41 -96
  52. package/starter/package.json +1 -1
  53. package/starter/src/data/social.json +14 -0
  54. package/theme/components/PostAuthor.astro +20 -0
  55. package/theme/components/PostCard.tsx +26 -0
  56. package/theme/components/PostHeader.astro +36 -0
  57. package/theme/components/PostMeta.tsx +27 -2
  58. package/theme/components/RelatedPosts.astro +29 -0
  59. package/theme/components/ShareRow.astro +48 -0
  60. package/theme/layouts/PostLayout.astro +28 -54
  61. package/theme/styles/base.css +164 -127
  62. package/theme/styles/blocks.css +160 -155
  63. package/theme/styles/components.css +43 -34
  64. package/theme/styles/global.css +316 -303
  65. package/theme/styles/ui.css +43 -34
  66. package/theme/ui/copyLink.script.ts +17 -0
package/site/schema.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // What is said of the business and the person beyond the contact details is the site's (@site/schema).
4
4
  // Only facts that are on the site; no ratings or reviews.
5
5
  import { site } from './site';
6
+ import { sameAsOf } from './social.mjs';
6
7
  import { schema as facts } from '@site/schema';
7
8
 
8
9
  /** What a site says of itself in the structured data (@site/schema). */
@@ -61,6 +62,7 @@ export const business = {
61
62
  hasMap: site.mapsUrl,
62
63
  ...(facts.offers ? { hasOfferCatalog: { '@id': abs(facts.offers) } } : {}),
63
64
  ...(facts.person ? { founder: { '@id': ids.person }, employee: { '@id': ids.person } } : {}),
65
+ ...(site.social.profiles.length ? { sameAs: sameAsOf(site.social.profiles) } : {}),
64
66
  };
65
67
 
66
68
  export const businessRef = { '@type': facts.businessType, '@id': ids.business, name: site.name, url: abs('/') };
package/site/site.ts CHANGED
@@ -1,5 +1,6 @@
1
- // Name, title, phone, email, address and CVR number: contact.json; the texts of "Book en tid": booking.json (the calendar
2
- // is the app Calendly's, src/apps/calendly/); the menus: menu.json and mobile-menu.json; the shared texts of the other blocks: their shared.ts, with those saved in
1
+ // Name, title, phone, email, address and CVR number: contact.json; the profiles and share buttons: social.json (optional, an
2
+ // older site has none); the texts of "Book en tid": booking.json (the calendar is the app Calendly's, src/apps/calendly/);
3
+ // the menus: menu.json and mobile-menu.json; the shared texts of the other blocks: their shared.ts, with those saved in
3
4
  // blocks.json over them (`blockTexts`).
4
5
  // The site's owner edits them in the editor (/editor/); what the editor does not edit is the site's config
5
6
  // (src/site/config.ts).
@@ -10,6 +11,7 @@ import mobileMenu from '@data/mobile-menu.json';
10
11
  import blocks from '@data/blocks.json';
11
12
  import standardPages from '@data/standard-pages.json';
12
13
  import { config } from '@site/config';
14
+ import { socialOf } from './social.mjs';
13
15
  import { sharedTexts, type SharedTexts } from '@morphemecms/cms/src/shared';
14
16
  import { standardShared } from '@morphemecms/cms/src/standard/sharedTexts';
15
17
 
@@ -19,6 +21,9 @@ const phoneIntl = `${contact.phone.trim().startsWith('+') ? '+' : '+45'}${contac
19
21
  // The CVR number, when it is filled in ("Virksomheden" › "Kontakt").
20
22
  const { cvr = '' } = contact as typeof contact & { cvr?: string };
21
23
 
24
+ // Optional file: an older site has no social.json, so it is read with a glob, not an import.
25
+ const socialFile = Object.values(import.meta.glob<Record<string, unknown>>('/src/data/social.json', { eager: true, import: 'default' }))[0];
26
+
22
27
  export const site = {
23
28
  name: contact.name,
24
29
  role: contact.role,
@@ -52,6 +57,8 @@ export const site = {
52
57
  /** The owner's picture (config.ts), or '' when the site has none. */
53
58
  portrait: config.picture?.src ?? '',
54
59
  portraitAlt: config.picture?.alt ?? '',
60
+ /** Profiles and share buttons (Company › Social media, social.json); all buttons on without the file. */
61
+ social: socialOf(socialFile),
55
62
  /** The logo in the structured data (config.ts), '/logo.svg' unless the site says otherwise. */
56
63
  logo: config.logo || '/logo.svg',
57
64
  };
@@ -0,0 +1,37 @@
1
+ /** @typedef {(typeof PROFILE_IDS)[number]} ProfileId */
2
+ /** @typedef {(typeof SHARE_IDS)[number]} ShareId */
3
+ /** @typedef {{ profiles: { id: ProfileId, url: string }[], share: ShareId[] }} Social */
4
+ /**
5
+ * @param {Record<string, unknown> | undefined} raw
6
+ * @returns {Social}
7
+ */
8
+ export function socialOf(raw: Record<string, unknown> | undefined): Social;
9
+ /**
10
+ * @param {{ url: string, title: string, picture?: string }} post
11
+ * @param {ShareId[]} share
12
+ * @returns {{ id: ShareId, href?: string }[]}
13
+ */
14
+ export function shareLinks(post: {
15
+ url: string;
16
+ title: string;
17
+ picture?: string;
18
+ }, share: ShareId[]): {
19
+ id: ShareId;
20
+ href?: string;
21
+ }[];
22
+ /**
23
+ * @param {Social['profiles']} profiles
24
+ * @returns {string[]}
25
+ */
26
+ export function sameAsOf(profiles: Social["profiles"]): string[];
27
+ export const PROFILE_IDS: readonly ["facebook", "instagram", "linkedin", "x", "youtube", "tiktok", "pinterest"];
28
+ export const SHARE_IDS: readonly ["facebook", "x", "linkedin", "pinterest", "copy-link"];
29
+ export type ProfileId = (typeof PROFILE_IDS)[number];
30
+ export type ShareId = (typeof SHARE_IDS)[number];
31
+ export type Social = {
32
+ profiles: {
33
+ id: ProfileId;
34
+ url: string;
35
+ }[];
36
+ share: ShareId[];
37
+ };
@@ -0,0 +1,92 @@
1
+ // The site's social settings (src/data/social.json, Company > Social media in the editor): its profiles, for the structured
2
+ // data (sameAs), and the share buttons under a blog post, with their addresses. Exposes socialOf, shareLinks, sameAsOf.
3
+
4
+ export const PROFILE_IDS = /** @type {const} */ (['facebook', 'instagram', 'linkedin', 'x', 'youtube', 'tiktok', 'pinterest']);
5
+ export const SHARE_IDS = /** @type {const} */ (['facebook', 'x', 'linkedin', 'pinterest', 'copy-link']);
6
+ /** @typedef {(typeof PROFILE_IDS)[number]} ProfileId */
7
+ /** @typedef {(typeof SHARE_IDS)[number]} ShareId */
8
+ /** @typedef {{ profiles: { id: ProfileId, url: string }[], share: ShareId[] }} Social */
9
+
10
+ /**
11
+ * @param {Record<string, unknown> | undefined} raw
12
+ * @returns {Social}
13
+ */
14
+ export function socialOf(raw) {
15
+ const data = raw || {};
16
+
17
+ // Profiles
18
+ const profiles = [];
19
+ for (const id of PROFILE_IDS) {
20
+ const value = data[id];
21
+ if (typeof value === 'string') {
22
+ const trimmed = value.trim();
23
+ if (trimmed !== '') {
24
+ profiles.push({ id, url: trimmed });
25
+ }
26
+ }
27
+ }
28
+
29
+ // Share switches
30
+ const switchMap = {
31
+ facebook: 'shareFacebook',
32
+ x: 'shareX',
33
+ linkedin: 'shareLinkedin',
34
+ pinterest: 'sharePinterest',
35
+ 'copy-link': 'shareCopyLink',
36
+ };
37
+
38
+ const share = [];
39
+ for (const id of SHARE_IDS) {
40
+ const key = switchMap[id];
41
+ const val = data[key];
42
+ if (val !== false) {
43
+ share.push(id);
44
+ }
45
+ }
46
+
47
+ return { profiles, share };
48
+ }
49
+
50
+ /**
51
+ * @param {{ url: string, title: string, picture?: string }} post
52
+ * @param {ShareId[]} share
53
+ * @returns {{ id: ShareId, href?: string }[]}
54
+ */
55
+ export function shareLinks(post, share) {
56
+ const links = [];
57
+ const url = encodeURIComponent(post.url);
58
+ const title = encodeURIComponent(post.title);
59
+ const picture = post.picture ? encodeURIComponent(post.picture) : '';
60
+
61
+ for (const id of share) {
62
+ let item = { id };
63
+
64
+ if (id === 'facebook') {
65
+ item.href = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
66
+ } else if (id === 'x') {
67
+ item.href = `https://x.com/intent/post?url=${url}&text=${title}`;
68
+ } else if (id === 'linkedin') {
69
+ item.href = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`;
70
+ } else if (id === 'pinterest') {
71
+ if (post.picture && post.picture !== '') {
72
+ item.href = `https://pinterest.com/pin/create/button/?url=${url}&media=${picture}&description=${title}`;
73
+ } else {
74
+ continue;
75
+ }
76
+ } else if (id === 'copy-link') {
77
+ // No href, just { id: 'copy-link' }
78
+ }
79
+
80
+ links.push(item);
81
+ }
82
+
83
+ return links;
84
+ }
85
+
86
+ /**
87
+ * @param {Social['profiles']} profiles
88
+ * @returns {string[]}
89
+ */
90
+ export function sameAsOf(profiles) {
91
+ return profiles.map((p) => p.url);
92
+ }
@@ -0,0 +1,80 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test } from 'node:test';
3
+ import { socialOf, shareLinks, sameAsOf } from './social.mjs';
4
+
5
+ test('no file', () => {
6
+ assert.deepEqual(socialOf(undefined), { profiles: [], share: ['facebook', 'x', 'linkedin', 'pinterest', 'copy-link'] });
7
+ });
8
+
9
+ test('empty object', () => {
10
+ assert.deepEqual(socialOf({}), { profiles: [], share: ['facebook', 'x', 'linkedin', 'pinterest', 'copy-link'] });
11
+ });
12
+
13
+ test('empty profiles are dropped', () => {
14
+ assert.deepEqual(socialOf({ facebook: '', x: ' ', linkedin: ' https://linkedin.com/in/a ' }).profiles, [
15
+ { id: 'linkedin', url: 'https://linkedin.com/in/a' },
16
+ ]);
17
+ });
18
+
19
+ test('profiles keep a fixed order', () => {
20
+ assert.deepEqual(
21
+ socialOf({ pinterest: 'https://p', facebook: 'https://f' }).profiles.map((p) => p.id),
22
+ ['facebook', 'pinterest'],
23
+ );
24
+ });
25
+
26
+ test('a switch turns a button off', () => {
27
+ assert.deepEqual(socialOf({ shareX: false }).share, ['facebook', 'linkedin', 'pinterest', 'copy-link']);
28
+ });
29
+
30
+ test('all switches off', () => {
31
+ assert.deepEqual(
32
+ socialOf({ shareFacebook: false, shareX: false, shareLinkedin: false, sharePinterest: false, shareCopyLink: false }).share,
33
+ [],
34
+ );
35
+ });
36
+
37
+ test('x encodes the address and title', () => {
38
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/b c/', title: 'Hi & bye' }, ['x']), [
39
+ { id: 'x', href: 'https://x.com/intent/post?url=https%3A%2F%2Fa.dk%2Fb%20c%2F&text=Hi%20%26%20bye' },
40
+ ]);
41
+ });
42
+
43
+ test('facebook', () => {
44
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/p/', title: 'T' }, ['facebook']), [
45
+ { id: 'facebook', href: 'https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fa.dk%2Fp%2F' },
46
+ ]);
47
+ });
48
+
49
+ test('linkedin', () => {
50
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/p/', title: 'T' }, ['linkedin']), [
51
+ { id: 'linkedin', href: 'https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fa.dk%2Fp%2F' },
52
+ ]);
53
+ });
54
+
55
+ test('pinterest with a picture', () => {
56
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/p/', title: 'T', picture: 'https://a.dk/i.webp' }, ['pinterest']), [
57
+ {
58
+ id: 'pinterest',
59
+ href: 'https://pinterest.com/pin/create/button/?url=https%3A%2F%2Fa.dk%2Fp%2F&media=https%3A%2F%2Fa.dk%2Fi.webp&description=T',
60
+ },
61
+ ]);
62
+ });
63
+
64
+ test('pinterest without a picture is left out', () => {
65
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/p/', title: 'T' }, ['pinterest', 'copy-link']), [{ id: 'copy-link' }]);
66
+ });
67
+
68
+ test('sameAs with no profiles', () => {
69
+ assert.deepEqual(sameAsOf([]), []);
70
+ });
71
+
72
+ test('sameAs keeps the order', () => {
73
+ assert.deepEqual(
74
+ sameAsOf([
75
+ { id: 'facebook', url: 'https://f' },
76
+ { id: 'x', url: 'https://x' },
77
+ ]),
78
+ ['https://f', 'https://x'],
79
+ );
80
+ });
package/site/theme.d.mts CHANGED
@@ -1,3 +1,5 @@
1
+ /** What is wrong with a theme's styles/global.css import (`css` is the file's text): null when it is right. */
2
+ export function importProblem(theme: any, css: any): string | null;
1
3
  /** The choice of icon pack made under "Indstillinger" › "Udseende" › "Ikonpakker" (src/data/icons.json, `{ "pack": "<id>"
2
4
  * }`), or undefined while none is made (then the theme's is used). */
3
5
  export function chosenPack(): any;
package/site/theme.mjs CHANGED
@@ -34,12 +34,16 @@ export const themeFolder = (theme) =>
34
34
  // What the standard theme has, as its folder in src/themes/: what the site's pages (packages/editor/pages/) and the editor
35
35
  // (src/site/) take from it as @theme. Besides these, the look of its blocks (blocks/<type>/) and what they import.
36
36
  // Another theme needs only theme.json and styles/global.css (OWN_FILES); the rest it may have, else it is the
37
- // standard theme's.
37
+ // standard theme's. A theme may replace each part of the post page (components/Post*, ShareRow, RelatedPosts) by a file
38
+ // of the same name.
38
39
  export const THEME_FILES = [
39
40
  'theme.json',
40
41
  'styles/global.css',
41
42
  'layouts/PageLayout.astro',
42
43
  'layouts/PostLayout.astro',
44
+ ...['PostHeader.astro', 'PostAuthor.astro', 'PostMeta.tsx', 'ShareRow.astro', 'PostCard.tsx', 'RelatedPosts.astro'].map(
45
+ (f) => `components/${f}`,
46
+ ),
43
47
  ...['Home', 'Blog', 'Category', 'New', 'NotFound'].map((t) => `templates/${t}.astro`),
44
48
  'editor/previews.tsx',
45
49
  'editor/looks.mjs',
@@ -63,9 +67,19 @@ const CONTRACT_FILES = ['index.tsx', 'shared.ts', 'texts.ts', 'list.ts', 'fields
63
67
  // How a block looked before it was drawn from its view, once on the site and once in the editor: now View.tsx.
64
68
  const OLD_LOOK = ['Site.astro', 'draw.tsx'];
65
69
 
66
- // An import of the UI components (ui.css) or of the standard theme's CSS that has them (base.css, global.css); the
67
- // standard theme's own global.css imports ./base.css.
68
- const UI_IMPORT = /@import\s+['"](?:@morphemecms\/cms\/theme\/styles\/|\.\/)(?:ui|base|global)\.css['"]/;
70
+ // The standard theme's own global.css imports ./base.css.
71
+ const STANDARD_IMPORT = /@import\s+['"]\.\/base\.css['"]/;
72
+ // Every other theme imports the standard theme's global.css, so Material is its fallback.
73
+ const FALLBACK_IMPORT = /@import\s+['"]@morphemecms\/cms\/theme\/styles\/global\.css['"]/;
74
+
75
+ /** What is wrong with a theme's styles/global.css import (`css` is the file's text): null when it is right. */
76
+ export function importProblem(theme, css) {
77
+ if (theme === STANDARD_THEME)
78
+ return STANDARD_IMPORT.test(css) ? null : `${themeFolder(theme)}/styles/global.css: importerer ikke ./base.css.`;
79
+ return FALLBACK_IMPORT.test(css)
80
+ ? null
81
+ : `${themeFolder(theme)}/styles/global.css: importerer ikke standardtemaet, som er temaets fallback: @import '@morphemecms/cms/theme/styles/global.css' øverst.`;
82
+ }
69
83
 
70
84
  const folders = (url) =>
71
85
  existsSync(url)
@@ -312,13 +326,13 @@ export function themeProblems(theme) {
312
326
  if (theme === STANDARD_THEME)
313
327
  for (const type of standard)
314
328
  if (!has(`${themeFolder(theme)}/blocks/${type}/View.tsx`)) problems.push(`${themeFolder(theme)}/blocks/${type}/View.tsx mangler.`);
315
- // Every theme draws the UI components (styles/ui.css: the buttons and icon buttons): it imports them, itself or
316
- // through the standard theme's base.css or global.css.
329
+ // Every theme builds on the standard theme. Another theme imports the standard theme's global.css, so a style it does
330
+ // not write is Material's; the standard theme's own imports ./base.css.
317
331
  const css = `${themeFolder(theme)}/styles/global.css`;
318
- if (has(css) && !UI_IMPORT.test(readFileSync(new URL(css, root), 'utf8')))
319
- problems.push(
320
- `${css}: importerer ikke temaets UI-dele (knapperne): @import '@morphemecms/cms/theme/styles/ui.css', eller standardtemaets base.css eller global.css, der gør det.`,
321
- );
332
+ if (has(css)) {
333
+ const problem = importProblem(theme, readFileSync(new URL(css, root), 'utf8'));
334
+ if (problem !== null) problems.push(problem);
335
+ }
322
336
  for (const type of folders(new URL(`${themeFolder(theme)}/blocks/`, root))) {
323
337
  const dir = `${themeFolder(theme)}/blocks/${type}`;
324
338
  if (!standard.has(type)) problems.push(`${dir}: et tema kan kun have udseendet af en standardblok, og "${type}" er ikke en.`);
@@ -0,0 +1,40 @@
1
+ // Tests of importProblem (site/theme.mjs): every theme imports Material's global.css as its fallback.
2
+ // Run with `node --test site/themeImport.test.mjs`.
3
+ import { strict as assert } from 'node:assert';
4
+ import { registerHooks } from 'node:module';
5
+ import { test } from 'node:test';
6
+
7
+ // theme.mjs imports this package by its name, which only works from an installed copy: point the name at this folder.
8
+ const PACKAGE = '@morphemecms/cms/';
9
+ const pkg = new URL('../', import.meta.url);
10
+ registerHooks({
11
+ resolve: (specifier, context, next) =>
12
+ specifier.startsWith(PACKAGE) ? next(new URL(specifier.slice(PACKAGE.length), pkg).href, context) : next(specifier, context),
13
+ });
14
+ const { importProblem } = await import('./theme.mjs');
15
+
16
+ const GLOBAL = '@morphemecms/cms/theme/styles/global.css';
17
+
18
+ test('another theme that imports the standard theme is right', () => {
19
+ assert.equal(importProblem('venus', `@import '${GLOBAL}';`), null);
20
+ assert.equal(importProblem('venus', `@import "${GLOBAL}";`), null);
21
+ });
22
+
23
+ test('another theme that imports base.css is told to import global.css', () => {
24
+ const problem = importProblem('venus', `@import '@morphemecms/cms/theme/styles/base.css';`);
25
+ assert.ok(problem.includes(GLOBAL));
26
+ });
27
+
28
+ test('another theme that imports ui.css is told where', () => {
29
+ const problem = importProblem('venus', `@import '@morphemecms/cms/theme/styles/ui.css';`);
30
+ assert.ok(problem.includes('src/themes/venus/styles/global.css'));
31
+ });
32
+
33
+ test('another theme with no import has a problem', () => {
34
+ assert.equal(typeof importProblem('venus', ':root { --radius: 1rem; }'), 'string');
35
+ });
36
+
37
+ test('the standard theme imports ./base.css', () => {
38
+ assert.equal(importProblem('saturn', `@import './base.css';`), null);
39
+ assert.ok(importProblem('saturn', ':root {}').includes('./base.css'));
40
+ });
package/src/EntryForm.tsx CHANGED
@@ -4,8 +4,8 @@
4
4
  // gives the same page). A new address (Adresse) moves the page or post, with a redirect from the
5
5
  // old one (addresses.ts). The Filer files the page newly refers to get the page's tags in the same commit (autoTags.mjs).
6
6
  // An earlier version (Versions.tsx) is opened as changes that are not saved,
7
- // at the address the page or post has now.
8
- // The form's sections are listed in a pane (large and wider) or a side sheet (below): SectionList in ui.tsx.
7
+ // at the address the page or post has now. Its preview draft says whether it is a post (post, for the lead).
8
+ // The form's sections are listed as "on this page" in a card at the end of the form (large and wider only): SectionList in ui.tsx.
9
9
  import { useEffect, useMemo, useRef, useState } from 'react';
10
10
  import { flushSync } from 'react-dom';
11
11
  import { slugify, slugOf, type Field, type MarkdownCollection } from './schema';
@@ -34,20 +34,7 @@ import { isOver } from '../lib/notice.mjs';
34
34
  import { splitPicture } from '../lib/crop.mjs';
35
35
  import { asStandardTemplate, blockProblems, notOffered, siteBlocks } from './registry';
36
36
  import { FieldInput, Fields, useWrongField, WrongField, type FieldContext } from './fields';
37
- import { Article, ClockCounterClockwise, Eye, MagnifyingGlass, ShareNetwork, SlidersHorizontal } from '@phosphor-icons/react';
38
- import {
39
- CheckList,
40
- FoldSection,
41
- FormColumns,
42
- type NavigationItem,
43
- SectionList,
44
- SectionPane,
45
- SectionSheet,
46
- SectionsButton,
47
- SharePreview,
48
- useAtLeast,
49
- useInView,
50
- } from './ui';
37
+ import { CheckList, FoldSection, FormColumns, type NavigationItem, SectionList, SectionPane, SharePreview, useInView } from './ui';
51
38
  import FormFrame, { savedStatus, useSavedAt } from './FormFrame';
52
39
  import { usePagePreview } from './pagePreview';
53
40
  import { savedOn, VersionList, VersionNotice, WithVersions, type ShownVersion } from './Versions';
@@ -67,8 +54,8 @@ const SHARE = SHARE_FIELDS;
67
54
  // The sections of the form, under each other on one page, each folding in and out under its heading (FoldSection in
68
55
  // ui.tsx): the text with the title and heading (open from the start), the status, the other fields, those for search
69
56
  // engines, those for social media, and the earlier versions (folded from the start, so the page is short; nothing is remembered till
70
- // the next time). Its list (on large and wider screens in a pane at the end of the form, narrower in a side sheet from the
71
- // button at the right of the top bar) opens one and scrolls to it, and marks the one in view. A field that keeps the form from being
57
+ // the next time). Its list (an "on this page" card at the end of the form, on large and wider screens only) opens one and
58
+ // scrolls to it, and marks the one in view. A field that keeps the form from being
72
59
  // saved has its section opened and is scrolled to (FieldInput in fields.tsx).
73
60
  type Section = 'content' | 'status' | 'other' | 'seo' | 'share' | 'versions';
74
61
  const sectionOf = (path: string): Section => {
@@ -226,19 +213,16 @@ function Form({
226
213
  (text) => notify({ text, error: true }),
227
214
  (path) => unfold(sectionOf(path)),
228
215
  );
229
- // On large and wider screens: the sections pane at the end of the form, until its button puts it away; narrower, the
230
- // sections are in a side sheet, opened by the same button.
231
- const large = useAtLeast('large');
232
- const [pane, setPane] = useState(true);
233
- const [sheet, setSheet] = useState(false);
216
+ // The form's name, in the top bar and over the sections list (the "on this page" card, on large and wider screens).
217
+ const formTitle = isNew ? collection.create! : String(initial.title || collection.singular);
234
218
  const has = (id: Section) => collection.fields.some(inSection(id));
235
219
  const sections: NavigationItem[] = [
236
- { id: 'content', label: t.forms.sections.content, icon: Article },
237
- ...(has('status') ? [{ id: 'status', label: t.forms.sections.status, icon: Eye }] : []),
238
- ...(has('other') ? [{ id: 'other', label: t.forms.otherFields, icon: SlidersHorizontal }] : []),
239
- ...(has('seo') ? [{ id: 'seo', label: t.forms.seo, icon: MagnifyingGlass }] : []),
240
- ...(has('share') ? [{ id: 'share', label: t.forms.share, icon: ShareNetwork }] : []),
241
- ...(isNew ? [] : [{ id: 'versions', label: t.history.versions, icon: ClockCounterClockwise }]),
220
+ { id: 'content', label: t.forms.sections.content },
221
+ ...(has('status') ? [{ id: 'status', label: t.forms.sections.status }] : []),
222
+ ...(has('other') ? [{ id: 'other', label: t.forms.otherFields }] : []),
223
+ ...(has('seo') ? [{ id: 'seo', label: t.forms.seo }] : []),
224
+ ...(has('share') ? [{ id: 'share', label: t.forms.share }] : []),
225
+ ...(isNew ? [] : [{ id: 'versions', label: t.history.versions }]),
242
226
  ];
243
227
  const shows = (id: Section) => sections.some((s) => s.id === id);
244
228
  // The section in view, marked in the sections list: while the page scrolls, the last one whose top is above a third of the
@@ -291,8 +275,7 @@ function Form({
291
275
  cancelAnimationFrame(frame);
292
276
  };
293
277
  }, []);
294
- // Opens a section and scrolls to it (or to `box` in it: the text), after the side sheet below large has been told to close (the page does
295
- // not scroll while a dialog is open). It is opened at once (flushSync), so it is there to scroll to.
278
+ // Opens a section and scrolls to it (or to `box` in it: the text). It is opened at once (flushSync), so it is there to scroll to.
296
279
  const textBox = useRef<HTMLDivElement>(null);
297
280
  const goTo = (id: Section, box?: HTMLElement | null) => {
298
281
  if (!open.has(id)) flushSync(() => unfold(id));
@@ -644,7 +627,9 @@ function Form({
644
627
  shell: isNew || (relaid && others.length) ? others : url,
645
628
  heading: h1?.heading ?? (changes('title', 'heading') ? String(values.heading || named().title) : undefined),
646
629
  headingHtml: h1?.html,
647
- // A post's picture at the top (a page has its pictures in its text).
630
+ // A post has a picture field (a page has its pictures in its text); the preview splits only a post's lead.
631
+ post: collection.fields.some((f) => f.name === 'image'),
632
+ // A post's picture at the top.
648
633
  image:
649
634
  collection.fields.some((f) => f.name === 'image') && changes('image', 'imageAlt', 'imageDecorative')
650
635
  ? values.image
@@ -673,17 +658,10 @@ function Form({
673
658
  <Fields.Provider value={fields}>
674
659
  <WrongField.Provider value={wrongField.field}>
675
660
  <FormFrame
676
- title={isNew ? collection.create! : String(initial.title || collection.singular)}
661
+ title={formTitle}
677
662
  back={{ href: `#liste/${collection.id}`, label: collection.label }}
678
663
  status={savedStatus({ savedAt, saving, deleting, isNew })}
679
664
  view={isNew ? undefined : url}
680
- drawer={
681
- <SectionsButton
682
- label={t.forms.sections.title}
683
- open={large ? pane : sheet}
684
- onClick={() => (large ? setPane(!pane) : setSheet(true))}
685
- />
686
- }
687
665
  dirty={dirty}
688
666
  saving={saving || deleting}
689
667
  deleting={deleting}
@@ -757,28 +735,13 @@ function Form({
757
735
  </>
758
736
  }
759
737
  pane={
760
- pane ? (
761
- <SectionPane title={t.forms.sections.title} open>
762
- <SectionList title={t.forms.sections.title} items={sections} value={section} onChange={pick} />
763
- </SectionPane>
764
- ) : undefined
738
+ <SectionPane caption={t.forms.sections.title} title={formTitle}>
739
+ <SectionList title={t.forms.sections.title} items={sections} value={section} onChange={pick} />
740
+ </SectionPane>
765
741
  }
766
742
  />
767
743
  </FormFrame>
768
744
  {blockDialog}
769
- {!large && (
770
- <SectionSheet title={t.forms.sections.title} open={sheet} onClose={() => setSheet(false)}>
771
- <SectionList
772
- title={t.forms.sections.title}
773
- items={sections}
774
- value={section}
775
- onChange={(id) => {
776
- setSheet(false);
777
- pick(id);
778
- }}
779
- />
780
- </SectionSheet>
781
- )}
782
745
  {picker}
783
746
  {previewSheet}
784
747
  </WrongField.Provider>
package/src/FormFrame.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  // What every form in the editor has around its fields: the top bar with the way back and a link to
2
2
  // the page on the site, "Slet" below the fields where entries can be deleted, and a bar at the bottom
3
- // of the screen with "Gem", which is only there when something has been changed. Messages are shown
4
- // by the app (Env.notify); the first "Gem" asks whether the editor may also send notifications.
3
+ // of the screen with "Gem" (across the bar on a phone, at its end from medium), which is only there when something
4
+ // has been changed. Messages are shown by the app (Env.notify); the first "Gem" asks whether the editor may also send notifications.
5
5
  // Leaving with changes that are not saved asks first, to save them or not (in the editor's dialog;
6
6
  // closing or reloading the page asks in the browser's own). "Forhåndsvis" beside "Gem" and in the top bar shows the page with
7
7
  // what is in the form, before it is saved (pagePreview.tsx).
@@ -32,7 +32,7 @@ type Props<T extends string> = {
32
32
  view?: string;
33
33
  /** More in the top bar, before the link to the site. */
34
34
  actions?: ReactNode;
35
- /** The button at the end of the top bar, after the account (a form's SectionsButton in ui.tsx). */
35
+ /** The button at the end of the top bar, after the account. */
36
36
  drawer?: ReactNode;
37
37
  /** The form's tabs, in the top bar (Screen). */
38
38
  tabs?: TabChoice<T>;
@@ -206,8 +206,8 @@ export default function FormFrame<T extends string>({
206
206
  {(dirty || saving) && (
207
207
  <div className="editor-save-bar fixed inset-x-0 bottom-0 z-20 border-t border-line bg-surface/90 pb-[env(safe-area-inset-bottom)] backdrop-blur-md">
208
208
  <div className="mx-auto max-w-3xl px-4 py-3 medium:px-6">
209
- {/* "Forhåndsvis" beside "Gem". */}
210
- <div className={onPreview ? 'grid grid-cols-[auto_1fr] gap-2' : ''}>
209
+ {/* "Forhåndsvis" beside "Gem": across the bar on a phone, at its end from medium. */}
210
+ <div className={['gap-2 medium:flex medium:justify-end', onPreview && 'grid grid-cols-[auto_1fr]'].filter(Boolean).join(' ')}>
211
211
  {onPreview && (
212
212
  <Button kind="outlined" icon={Binoculars} aria-haspopup="dialog" onClick={onPreview}>
213
213
  {t.preview.button}
package/src/Parts.tsx CHANGED
@@ -7,9 +7,6 @@
7
7
  import { useMemo, useRef, useState } from 'react';
8
8
  import {
9
9
  AppWindow,
10
- Article,
11
- Eye,
12
- SlidersHorizontal,
13
10
  Gear,
14
11
  ArrowDown,
15
12
  ArrowsDownUp,
@@ -97,8 +94,6 @@ import {
97
94
  MenuRow,
98
95
  SectionList,
99
96
  SectionPane,
100
- SectionSheet,
101
- SectionsButton,
102
97
  } from './ui';
103
98
  import { FieldList, Fields, useWrongField, WrongField, type FieldContext } from './fields';
104
99
  import { newValue, problemAt } from './check';
@@ -146,12 +141,11 @@ export default function Parts({ env }: { env: Env }) {
146
141
  const [picture, setPicture] = useState(examplePicture);
147
142
  const [folded, setFolded] = useState<number | null>(0);
148
143
  const [sectionOpen, setSectionOpen] = useState<number | null>(0);
149
- const [sectionsSheet, setSectionsSheet] = useState(false);
150
144
  const [sectionPicked, setSectionPicked] = useState('content');
151
145
  const sectionItems = [
152
- { id: 'content', label: t.forms.sections.content, icon: Article },
153
- { id: 'status', label: t.forms.sections.status, icon: Eye, badge: 2 },
154
- { id: 'other', label: t.forms.otherFields, icon: SlidersHorizontal },
146
+ { id: 'content', label: t.forms.sections.content },
147
+ { id: 'status', label: t.forms.sections.status, badge: 2 },
148
+ { id: 'other', label: t.forms.otherFields },
155
149
  ];
156
150
  const [tags, setTags] = useState(['a', 'b']);
157
151
  const dialog = useRef<HTMLDialogElement>(null);
@@ -707,28 +701,12 @@ export default function Parts({ env }: { env: Env }) {
707
701
  </div>
708
702
  </Part>
709
703
 
710
- <Part name="SectionList, SectionPane, SectionSheet, SectionsButton" use={use.sectionList}>
711
- <div className="space-y-4">
712
- <div className={row}>
713
- <SectionsButton label={t.forms.sections.title} open={sectionsSheet} onClick={() => setSectionsSheet(true)} />
714
- </div>
715
- <div className="max-w-sm">
716
- <SectionPane title={t.forms.sections.title} open>
717
- <SectionList title={t.forms.sections.title} items={sectionItems} value={sectionPicked} onChange={setSectionPicked} />
718
- </SectionPane>
719
- </div>
704
+ <Part name="SectionList, SectionPane" use={use.sectionList}>
705
+ <div className="max-w-xs">
706
+ <SectionPane caption={t.forms.sections.title} title={examples.pages[0]}>
707
+ <SectionList title={t.forms.sections.title} items={sectionItems} value={sectionPicked} onChange={setSectionPicked} />
708
+ </SectionPane>
720
709
  </div>
721
- <SectionSheet title={t.forms.sections.title} open={sectionsSheet} onClose={() => setSectionsSheet(false)}>
722
- <SectionList
723
- title={t.forms.sections.title}
724
- items={sectionItems}
725
- value={sectionPicked}
726
- onChange={(id) => {
727
- setSectionPicked(id);
728
- setSectionsSheet(false);
729
- }}
730
- />
731
- </SectionSheet>
732
710
  </Part>
733
711
 
734
712
  <Part name="SharePreview" use={use.sharePreview}>