create-smoodly-app 0.0.12 → 0.0.14
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/package.json +1 -1
- package/templates/next/app/globals.css +20 -0
- package/templates/next/app/layout.tsx +5 -6
- package/templates/next/components/elements/Callout.tsx +35 -0
- package/templates/next/components/elements/Cta.tsx +35 -0
- package/templates/next/components/pages/Article.tsx +5 -3
- package/templates/next/components/pages/Articles.tsx +7 -7
- package/templates/next/components/pages/Page.tsx +11 -1
- package/templates/next/components/sections/FavouriteArticles.tsx +4 -2
- package/templates/next/components/sections/Footer.tsx +15 -10
- package/templates/next/components/sections/Header.tsx +7 -2
- package/templates/next/components/sections/Hero.tsx +13 -7
- package/templates/next/components/sections/ImageText.tsx +11 -7
- package/templates/next/components/sections/PageHero.tsx +6 -4
- package/templates/next/components/sections/People.tsx +4 -2
- package/templates/next/components/sections/Points.tsx +16 -11
- package/templates/next/components/sections/Quote.tsx +4 -2
- package/templates/next/package.json +1 -1
- package/templates/next/scripts/seed.ts +39 -5
- package/templates/next/smoodly/collections.ts +7 -4
- package/templates/next/smoodly/config.ts +4 -1
- package/templates/next/smoodly/elements.ts +6 -0
- package/templates/next/smoodly/richtext.tsx +7 -0
- package/templates/next/smoodly/server.ts +20 -16
- package/templates/next/smoodly/toolsets.ts +16 -0
- package/templates/next/supabase/migrations/00000000000000_smoodly.sql +4 -4
- package/templates/next-intl/app/[locale]/layout.tsx +5 -6
- package/templates/next-intl/app/globals.css +20 -0
- package/templates/next-intl/components/elements/Callout.tsx +35 -0
- package/templates/next-intl/components/elements/Cta.tsx +35 -0
- package/templates/next-intl/components/pages/Article.tsx +5 -3
- package/templates/next-intl/components/pages/Articles.tsx +7 -7
- package/templates/next-intl/components/pages/Page.tsx +11 -1
- package/templates/next-intl/components/sections/FavouriteArticles.tsx +4 -2
- package/templates/next-intl/components/sections/Footer.tsx +15 -10
- package/templates/next-intl/components/sections/Header.tsx +7 -2
- package/templates/next-intl/components/sections/Hero.tsx +13 -7
- package/templates/next-intl/components/sections/ImageText.tsx +11 -7
- package/templates/next-intl/components/sections/PageHero.tsx +6 -4
- package/templates/next-intl/components/sections/People.tsx +4 -2
- package/templates/next-intl/components/sections/Points.tsx +16 -11
- package/templates/next-intl/components/sections/Quote.tsx +4 -2
- package/templates/next-intl/package.json +1 -1
- package/templates/next-intl/scripts/seed.ts +39 -5
- package/templates/next-intl/smoodly/collections.ts +7 -4
- package/templates/next-intl/smoodly/config.ts +4 -1
- package/templates/next-intl/smoodly/elements.ts +6 -0
- package/templates/next-intl/smoodly/richtext.tsx +7 -0
- package/templates/next-intl/smoodly/server.ts +20 -16
- package/templates/next-intl/smoodly/toolsets.ts +16 -0
- package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +4 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { f, smoodly } from "smoodly";
|
|
2
|
+
import { article } from "./toolsets";
|
|
2
3
|
|
|
3
4
|
// A person's name is the same in every language; the role is translated.
|
|
4
5
|
// Shared fields live on the entry, .localized() ones on its locale rows
|
|
@@ -7,6 +8,7 @@ export const people = smoodly.collection({
|
|
|
7
8
|
name: "people",
|
|
8
9
|
title: "People",
|
|
9
10
|
titleField: "name",
|
|
11
|
+
order: "manual",
|
|
10
12
|
fields: {
|
|
11
13
|
name: f.text(),
|
|
12
14
|
role: f.text().localized(),
|
|
@@ -29,13 +31,14 @@ export const articles = smoodly.collection({
|
|
|
29
31
|
versions: true,
|
|
30
32
|
fields: {
|
|
31
33
|
title: f.text().localized(),
|
|
32
|
-
slug: f.slug({ from: "title" }),
|
|
33
|
-
excerpt: f.text().localized(),
|
|
34
|
-
body: f.richtext().localized(),
|
|
35
|
-
tags: f.list(f.text()).localized(),
|
|
36
34
|
image: f.image({ aspect: "3:2" }),
|
|
37
35
|
date: f.date(),
|
|
38
36
|
author: f.ref(() => people),
|
|
37
|
+
slug: f.slug({ from: "title" }),
|
|
38
|
+
excerpt: f.text().localized(),
|
|
39
|
+
// The article toolset: the default marks and blocks, body images and the elements.
|
|
40
|
+
body: f.richtext({ toolset: article }).localized(),
|
|
41
|
+
tags: f.list(f.text()).localized(),
|
|
39
42
|
},
|
|
40
43
|
});
|
|
41
44
|
|
|
@@ -2,12 +2,13 @@ import { defineConfig, f } from "smoodly";
|
|
|
2
2
|
import { collections } from "./collections";
|
|
3
3
|
import { sections } from "./sections";
|
|
4
4
|
import { shared } from "./shared";
|
|
5
|
+
import { elements } from "./elements";
|
|
5
6
|
import { HomePage } from "@/components/pages/Home";
|
|
6
7
|
import { ArticlesPage } from "@/components/pages/Articles";
|
|
7
8
|
import { StandardPage } from "@/components/pages/Page";
|
|
8
9
|
import { ArticlePage } from "@/components/pages/Article";
|
|
9
10
|
|
|
10
|
-
export { collections, sections, shared };
|
|
11
|
+
export { collections, sections, shared, elements };
|
|
11
12
|
|
|
12
13
|
// The registry and the plain settings, one file. Runtime-free: nothing
|
|
13
14
|
// here imports a route file or Next, so a script, the CLI's template
|
|
@@ -15,6 +16,8 @@ export { collections, sections, shared };
|
|
|
15
16
|
export const config = defineConfig({
|
|
16
17
|
registry: {
|
|
17
18
|
sections,
|
|
19
|
+
// Elements (block nodes spec 2026-09-08): what an article body may embed.
|
|
20
|
+
elements,
|
|
18
21
|
collections,
|
|
19
22
|
shared,
|
|
20
23
|
pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The site's element registrations, in one list: the config registers
|
|
2
|
+
// them, toolsets embed them by value, and smoodly/richtext.tsx renders them.
|
|
3
|
+
import { Callout } from "@/components/elements/Callout";
|
|
4
|
+
import { Cta } from "@/components/elements/Cta";
|
|
5
|
+
|
|
6
|
+
export const elements = [Callout, Cta];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The site's richtext renderer, bound once: it knows the elements a body
|
|
2
|
+
// may embed and renders body images through <SmoodlyImage>. Sections and
|
|
3
|
+
// entry pages import RichText from here, never from the package root.
|
|
4
|
+
import { createRichText } from "smoodly/richtext";
|
|
5
|
+
import { elements } from "./elements";
|
|
6
|
+
|
|
7
|
+
export const RichText = createRichText({ elements, sizes: "(min-width: 900px) 720px, 100vw" });
|
|
@@ -4,21 +4,22 @@
|
|
|
4
4
|
// secret is a service-role key or a cloud write key is invisible here.
|
|
5
5
|
//
|
|
6
6
|
// Lazy on purpose, the site and renderer included: config.ts imports the
|
|
7
|
-
// page shapes, and a page shape may import this file to
|
|
8
|
-
//
|
|
7
|
+
// page shapes, and a page shape may import this file to read through
|
|
8
|
+
// `q` (components/pages/Articles.tsx). That is a cycle, and the
|
|
9
9
|
// admin route enters it from config.ts — so nothing here may read the
|
|
10
10
|
// config at module scope.
|
|
11
11
|
import {
|
|
12
12
|
createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
|
|
13
13
|
} from "smoodly";
|
|
14
14
|
import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
|
|
15
|
-
import { createPageRenderer, type PageRegistration } from "smoodly/next";
|
|
15
|
+
import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
|
|
16
16
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
17
|
-
import { collections, config, sections, shared } from "./config";
|
|
17
|
+
import { collections, config, elements, sections, shared } from "./config";
|
|
18
18
|
|
|
19
19
|
type Renderer = ReturnType<typeof createPageRenderer>;
|
|
20
|
+
type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof shared>>;
|
|
20
21
|
type Bound = {
|
|
21
|
-
db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer;
|
|
22
|
+
db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
let cached: Bound | null = null;
|
|
@@ -30,9 +31,9 @@ function bound(): Bound {
|
|
|
30
31
|
// ONE path index for both stores: pages and entries share the URL space.
|
|
31
32
|
const paths = new SupabasePathIndex(db);
|
|
32
33
|
const locales = config.locales;
|
|
33
|
-
const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared });
|
|
34
|
+
const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared, elements });
|
|
34
35
|
const pages = new SupabasePageStore(db, {
|
|
35
|
-
sections, entries, paths, locales,
|
|
36
|
+
sections, elements, entries, paths, locales,
|
|
36
37
|
homePageSlug: config.homePageSlug,
|
|
37
38
|
tree: config.pages?.tree,
|
|
38
39
|
});
|
|
@@ -43,17 +44,18 @@ function bound(): Bound {
|
|
|
43
44
|
// takes this local closure, not the export, so it never re-enters `bound()`.
|
|
44
45
|
const stores = () => ({ pages, entries, assets });
|
|
45
46
|
// The site layer: (locale, path) → page or entry, and the content API
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
const site = createSmoodlySite({ stores, collections, shared, sections, locales, homePageSlug: config.homePageSlug });
|
|
47
|
+
// `q` wraps below. Not exported — the app reads through `q`, which adds
|
|
48
|
+
// draft awareness and the cache.
|
|
49
|
+
const site = createSmoodlySite({ stores, collections, shared, sections, elements, locales, homePageSlug: config.homePageSlug, pages: config.registry.pages ?? [] });
|
|
50
50
|
const renderer = createPageRenderer({
|
|
51
51
|
site,
|
|
52
52
|
sections,
|
|
53
53
|
pages: (config.registry.pages ?? []) as PageRegistration[],
|
|
54
54
|
href,
|
|
55
55
|
});
|
|
56
|
-
|
|
56
|
+
// The read API (spec 2026-09-11): typed by the registry, draft-aware, cached under tags the admin expires.
|
|
57
|
+
const q = createSmoodlyQueries({ site, collections, shared });
|
|
58
|
+
cached = { db, pages, entries, assets, auth, site, renderer, q };
|
|
57
59
|
return cached;
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -80,13 +82,15 @@ export function publicAuth(): AuthConfig {
|
|
|
80
82
|
* becomes a locale-prefixing lookup. */
|
|
81
83
|
export const href = (path: string, _locale: string) => path;
|
|
82
84
|
|
|
83
|
-
/** The
|
|
84
|
-
*
|
|
85
|
-
|
|
85
|
+
/** The read API (spec 2026-09-11) for layouts and page views that fetch:
|
|
86
|
+
* `q().getEntries("articles", { locale, limit: 3 })`, `q().getShared("header",
|
|
87
|
+
* { locale })`, `q().getPageTree({ locale })`. Typed by the registry, the
|
|
88
|
+
* live draft in the editor preview, cached under tags the admin expires.
|
|
89
|
+
* Rename it if `q` is not to your taste — this file is yours. */
|
|
90
|
+
export const q = (): Queries => bound().q;
|
|
86
91
|
|
|
87
92
|
/** The two route files call these. */
|
|
88
93
|
export const renderSmoodlyPath: Renderer["renderSmoodlyPath"] = (...args) => bound().renderer.renderSmoodlyPath(...args);
|
|
89
94
|
export const smoodlyMetadata: Renderer["smoodlyMetadata"] = (...args) => bound().renderer.smoodlyMetadata(...args);
|
|
90
95
|
export const renderSmoodlyPage: Renderer["renderSmoodlyPage"] = (...args) => bound().renderer.renderSmoodlyPage(...args);
|
|
91
96
|
export const smoodlyPageMetadata: Renderer["smoodlyPageMetadata"] = (...args) => bound().renderer.smoodlyPageMetadata(...args);
|
|
92
|
-
export const getSmoodlyShared: Renderer["getSmoodlyShared"] = (...args) => bound().renderer.getSmoodlyShared(...args);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Toolsets are values, not registrations: a richtext field imports one.
|
|
2
|
+
// `article` is the long-form set — the default marks and blocks plus body
|
|
3
|
+
// images and the two elements. Elements are embedded by value; the
|
|
4
|
+
// config must also register them (defineConfig checks). Relative imports
|
|
5
|
+
// on purpose: collections.ts pulls this file into create-smoodly-app's
|
|
6
|
+
// template build, which runs outside the app where the `@/` alias has
|
|
7
|
+
// no mapping.
|
|
8
|
+
import { smoodly } from "smoodly";
|
|
9
|
+
import { Callout } from "../components/elements/Callout";
|
|
10
|
+
import { Cta } from "../components/elements/Cta";
|
|
11
|
+
|
|
12
|
+
export const article = smoodly.toolset({
|
|
13
|
+
marks: ["bold", "italic", "link"],
|
|
14
|
+
nodes: ["h2", "h3", "h4", "bulletList", "orderedList", "blockquote", "image"],
|
|
15
|
+
elements: [Callout, Cta],
|
|
16
|
+
});
|
|
@@ -521,12 +521,12 @@ select
|
|
|
521
521
|
e."created_at",
|
|
522
522
|
l."updated_at",
|
|
523
523
|
v."fields"->>'title' as "title",
|
|
524
|
-
v."fields"->>'excerpt' as "excerpt",
|
|
525
|
-
v."fields"->'body' as "body",
|
|
526
|
-
v."fields"->'tags' as "tags",
|
|
527
524
|
v."fields"->'image' as "image",
|
|
528
525
|
(v."fields"->>'date')::timestamptz as "date",
|
|
529
|
-
v."fields"->>'author' as "author"
|
|
526
|
+
v."fields"->>'author' as "author",
|
|
527
|
+
v."fields"->>'excerpt' as "excerpt",
|
|
528
|
+
v."fields"->'body' as "body",
|
|
529
|
+
v."fields"->'tags' as "tags"
|
|
530
530
|
from "entries" e
|
|
531
531
|
join "entry_locales" l on l."entry_id" = e."id"
|
|
532
532
|
join "entry_versions" v on v."id" = l."published_version_id"
|
|
@@ -5,8 +5,7 @@ import { hasLocale, NextIntlClientProvider } from "next-intl";
|
|
|
5
5
|
import { routing } from "@/i18n/routing";
|
|
6
6
|
import { HeaderView } from "@/components/sections/Header";
|
|
7
7
|
import { FooterView } from "@/components/sections/Footer";
|
|
8
|
-
import {
|
|
9
|
-
import { getSmoodlyShared, href } from "@/smoodly/server";
|
|
8
|
+
import { href, q } from "@/smoodly/server";
|
|
10
9
|
import "../globals.css";
|
|
11
10
|
|
|
12
11
|
// The starter's two faces, as CSS variables globals.css reads.
|
|
@@ -18,16 +17,16 @@ export const metadata = { title: "Lorem Co." };
|
|
|
18
17
|
// The site's root layout. The admin has its own under app/(smoodly)/admin —
|
|
19
18
|
// two root layouts, a full page load between them, which is fine: they
|
|
20
19
|
// are separate apps in spirit. Header, footer and the contact block are
|
|
21
|
-
// shared content:
|
|
20
|
+
// shared content: read through `q`, cached per locale under their tag, null
|
|
22
21
|
// until published in this locale. The header also gets the locale links
|
|
23
22
|
// — the visitor's only language switch, since locale detection is off.
|
|
24
23
|
export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
|
|
25
24
|
const { locale } = await params;
|
|
26
25
|
if (!hasLocale(routing.locales, locale)) notFound();
|
|
27
26
|
const [headerProps, footerProps, contactFields] = await Promise.all([
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
q().getShared("header", { locale }),
|
|
28
|
+
q().getShared("footer", { locale }),
|
|
29
|
+
q().getShared("contact", { locale }),
|
|
31
30
|
]);
|
|
32
31
|
const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
|
|
33
32
|
return (
|
|
@@ -63,6 +63,22 @@ address { font-style: normal; }
|
|
|
63
63
|
.prose--lead { gap: 24px; font-size: 18px; line-height: 1.7; color: var(--body); }
|
|
64
64
|
.prose--lead > p:first-child { font-family: var(--serif); font-size: 24px; line-height: 1.45; color: var(--ink); }
|
|
65
65
|
|
|
66
|
+
/* ---------- richtext block nodes: images, callout, cta ---------- */
|
|
67
|
+
.prose figure { margin: 8px 0; }
|
|
68
|
+
.prose figure img { width: 100%; height: auto; border-radius: var(--radius); display: block; }
|
|
69
|
+
.prose figcaption { margin-top: 8px; font-size: 14px; color: var(--muted); }
|
|
70
|
+
.prose .callout { margin: 8px 0; padding: 20px 24px; border-radius: var(--radius); border-left: 3px solid var(--accent); background: #fff; display: flex; flex-direction: column; gap: 8px; font-size: 16px; line-height: 1.6; }
|
|
71
|
+
.prose .callout[data-tone="success"] { border-left-color: #3a8f5c; }
|
|
72
|
+
.prose .callout[data-tone="warning"] { border-left-color: #c9962b; background: #fff8e8; }
|
|
73
|
+
.prose .callout__title { color: var(--ink); font-size: 15px; letter-spacing: 0.02em; }
|
|
74
|
+
.prose .callout__body { display: flex; flex-direction: column; gap: 8px; }
|
|
75
|
+
.prose .cta { margin: 16px 0; padding: 28px 32px; border-radius: var(--radius); background: var(--ink); color: var(--paper); display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
|
|
76
|
+
.prose .cta[data-variant="outline"] { background: transparent; color: var(--ink); border: 1px solid var(--line-strong); }
|
|
77
|
+
.prose .cta__heading { font-family: var(--serif); font-size: 24px; font-weight: 500; line-height: 1.2; display: block; }
|
|
78
|
+
.prose .cta__text { margin: 6px 0 0; font-size: 15px; color: inherit; opacity: 0.8; }
|
|
79
|
+
.prose .cta .button { background: var(--paper); color: var(--ink); flex: none; }
|
|
80
|
+
.prose .cta[data-variant="outline"] .button { background: var(--ink); color: var(--paper); }
|
|
81
|
+
|
|
66
82
|
/* ---------- section styles: the config's spacing and tone ---------- */
|
|
67
83
|
|
|
68
84
|
/* Every section in the page body gets the medium spacing by element,
|
|
@@ -157,6 +173,10 @@ h3.card__title { font-size: 24px; }
|
|
|
157
173
|
.breadcrumb { padding-top: 24px; font-size: 14px; color: var(--muted); }
|
|
158
174
|
.breadcrumb a:hover { color: var(--accent); }
|
|
159
175
|
|
|
176
|
+
.subnav { display: flex; flex-wrap: wrap; gap: 20px; padding-top: 12px; font-size: 14px; }
|
|
177
|
+
.subnav a { color: var(--muted); }
|
|
178
|
+
.subnav a:hover { color: var(--accent); }
|
|
179
|
+
|
|
160
180
|
.articles-index { padding-bottom: 96px; }
|
|
161
181
|
|
|
162
182
|
.article { padding-top: 48px; display: flex; flex-direction: column; gap: 40px; }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { f, RichText, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// An Element: a small registered block editors insert inside an article
|
|
4
|
+
// body (the `richtext` context). Its body is richtext too — marks only —
|
|
5
|
+
// rendered by the package's pure RichText (never the project's bound one:
|
|
6
|
+
// smoodly/richtext.tsx imports this file, and an import back would cycle).
|
|
7
|
+
export const calloutSchema = smoodly.schema.element({
|
|
8
|
+
name: "callout",
|
|
9
|
+
title: "Callout",
|
|
10
|
+
icon: "!",
|
|
11
|
+
description: "A highlighted note inside the text.",
|
|
12
|
+
contexts: ["richtext"],
|
|
13
|
+
fields: {
|
|
14
|
+
title: f.text().max(80),
|
|
15
|
+
body: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
|
|
16
|
+
},
|
|
17
|
+
styles: {
|
|
18
|
+
tone: f.select([{ value: "info", label: "Info" }, { value: "success", label: "Success" }, { value: "warning", label: "Warning" }]).default("info"),
|
|
19
|
+
},
|
|
20
|
+
sample: {
|
|
21
|
+
title: "Good to know",
|
|
22
|
+
body: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit." }] }] },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function CalloutView({ title, body, styles }: Props<typeof calloutSchema>) {
|
|
27
|
+
return (
|
|
28
|
+
<aside className="callout" data-tone={styles.tone}>
|
|
29
|
+
<strong className="callout__title">{title}</strong>
|
|
30
|
+
<div className="callout__body"><RichText doc={body} /></div>
|
|
31
|
+
</aside>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const Callout = smoodly.element(calloutSchema, CalloutView);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// A call to action inside an article body: heading, a line of text and
|
|
4
|
+
// one button. `variant` is a style — never translated.
|
|
5
|
+
export const ctaSchema = smoodly.schema.element({
|
|
6
|
+
name: "cta",
|
|
7
|
+
title: "Call to action",
|
|
8
|
+
icon: "→",
|
|
9
|
+
description: "A heading, a line and a button.",
|
|
10
|
+
contexts: ["richtext"],
|
|
11
|
+
fields: {
|
|
12
|
+
heading: f.text().max(80),
|
|
13
|
+
text: f.text().max(200).optional(),
|
|
14
|
+
label: f.text().max(40),
|
|
15
|
+
href: f.link(),
|
|
16
|
+
},
|
|
17
|
+
styles: {
|
|
18
|
+
variant: f.select([{ value: "primary", label: "Primary" }, { value: "outline", label: "Outline" }]).default("primary"),
|
|
19
|
+
},
|
|
20
|
+
sample: { heading: "Ready to start?", text: "Sed ut perspiciatis unde omnis iste natus.", label: "Get in touch", href: "/company/about-us" },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function CtaView({ heading, text, label, href, styles }: Props<typeof ctaSchema>) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="cta" data-variant={styles.variant}>
|
|
26
|
+
<div className="cta__copy">
|
|
27
|
+
<strong className="cta__heading">{heading}</strong>
|
|
28
|
+
{text && <p className="cta__text">{text}</p>}
|
|
29
|
+
</div>
|
|
30
|
+
<a className={styles.variant === "outline" ? "button button--outline" : "button"} href={href}>{label}</a>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const Cta = smoodly.element(ctaSchema, CtaView);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { smoodly, type EntryPageProps } from "smoodly";
|
|
2
|
+
import { RichText } from "@/smoodly/richtext";
|
|
2
3
|
import { SmoodlyImage } from "smoodly/image";
|
|
3
4
|
import { articles } from "@/smoodly/collections";
|
|
4
|
-
import {
|
|
5
|
+
import { q } from "@/smoodly/server";
|
|
5
6
|
import { ArticleCard } from "../ArticleCard";
|
|
6
7
|
import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
|
|
7
8
|
import { ui } from "../strings";
|
|
@@ -15,7 +16,8 @@ import { ui } from "../strings";
|
|
|
15
16
|
async function ArticleView({ entry }: EntryPageProps) {
|
|
16
17
|
const { locale, t, url } = await ui();
|
|
17
18
|
const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
|
|
18
|
-
|
|
19
|
+
// Four newest, minus this one, so three remain even when it is among them; the store applies the limit.
|
|
20
|
+
const related = (await q().getEntries("articles", { locale, limit: 4 })).filter((r) => r.entry.id !== entry.id).slice(0, 3);
|
|
19
21
|
const index = url(articlesIndexPath(locale));
|
|
20
22
|
return (
|
|
21
23
|
<>
|
|
@@ -3,16 +3,16 @@ import { PageHero } from "../sections/PageHero";
|
|
|
3
3
|
import { ArticleCard } from "../ArticleCard";
|
|
4
4
|
import { articlePath, type ArticleFields } from "../articles";
|
|
5
5
|
import { ui } from "../strings";
|
|
6
|
-
import {
|
|
6
|
+
import { q } from "@/smoodly/server";
|
|
7
7
|
|
|
8
8
|
// The Articles index: a fixed page whose slugs are the collection's own
|
|
9
9
|
// segments (smoodly/collections.ts), which makes it the collection's
|
|
10
10
|
// index page — the mount in the Pages list opens this page's editor. The
|
|
11
|
-
// editor owns the hero; the grid below is developer JSX that
|
|
12
|
-
//
|
|
13
|
-
// collection's
|
|
14
|
-
// the grid
|
|
15
|
-
//
|
|
11
|
+
// editor owns the hero; the grid below is developer JSX that reads the
|
|
12
|
+
// articles through `q`, newest first (the collection's `order`), cached
|
|
13
|
+
// under the collection's tag. In the editor canvas the site runs in draft
|
|
14
|
+
// mode, so the grid reads the live rows too: an unpublished article shows
|
|
15
|
+
// there as it will (spec 2026-09-11 §2).
|
|
16
16
|
export const articlesSchema = smoodly.schema.page({
|
|
17
17
|
name: "articlesIndex",
|
|
18
18
|
title: "Articles",
|
|
@@ -25,7 +25,7 @@ export const articlesSchema = smoodly.schema.page({
|
|
|
25
25
|
|
|
26
26
|
async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
|
|
27
27
|
const { locale, url } = await ui();
|
|
28
|
-
const items = await
|
|
28
|
+
const items = await q().getEntries("articles", { locale });
|
|
29
29
|
return (
|
|
30
30
|
<>
|
|
31
31
|
<Zone of={zones.hero} />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
|
-
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
2
|
+
import { f, smoodly, z, Zone, type Props } from "smoodly";
|
|
3
3
|
import { PageHero } from "../sections/PageHero";
|
|
4
4
|
import { ImageText } from "../sections/ImageText";
|
|
5
5
|
import { Points } from "../sections/Points";
|
|
@@ -19,9 +19,14 @@ export const pageSchema = smoodly.schema.page({
|
|
|
19
19
|
body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote] }),
|
|
20
20
|
},
|
|
21
21
|
meta: { seo: true },
|
|
22
|
+
// A page field (spec 2026-09-10 page fields §7): the parent's view lists
|
|
23
|
+
// the children that keep this on. Edited on the Settings tab.
|
|
24
|
+
fields: { showInNavigation: f.boolean().default(true) },
|
|
22
25
|
});
|
|
23
26
|
|
|
24
27
|
function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
28
|
+
// Sub-navigation: the published children that opted in through their page field.
|
|
29
|
+
const shown = page.children.filter((c) => c.hasPage && c.fields.showInNavigation === true);
|
|
25
30
|
return (
|
|
26
31
|
<>
|
|
27
32
|
{page.ancestors.length > 0 && (
|
|
@@ -34,6 +39,11 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
|
34
39
|
{page.title}
|
|
35
40
|
</nav>
|
|
36
41
|
)}
|
|
42
|
+
{shown.length > 0 && (
|
|
43
|
+
<nav className="container subnav" aria-label="Pages in this section">
|
|
44
|
+
{shown.map((c) => <Link key={c.path} href={c.url}>{c.title}</Link>)}
|
|
45
|
+
</nav>
|
|
46
|
+
)}
|
|
37
47
|
<Zone of={zones.hero} />
|
|
38
48
|
<Zone of={zones.body} />
|
|
39
49
|
</>
|
|
@@ -19,6 +19,8 @@ export const favouriteArticlesSchema = smoodly.schema.section({
|
|
|
19
19
|
sample: { heading: "From the journal", items: [] },
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
const m = smoodly.marks(favouriteArticlesSchema);
|
|
23
|
+
|
|
22
24
|
// async: the cards format dates in the request locale and link through
|
|
23
25
|
// the locale's article segment, both from the strings seam. The canvas
|
|
24
26
|
// is the site's own draft route in an iframe, so an async section
|
|
@@ -28,8 +30,8 @@ async function FavouriteArticlesView({ heading, items, styles }: Props<typeof fa
|
|
|
28
30
|
return (
|
|
29
31
|
<smoodly.Section styles={styles} className="favourites">
|
|
30
32
|
<div className="container">
|
|
31
|
-
<h2 className="h2">{heading}</h2>
|
|
32
|
-
<div className="card-grid">
|
|
33
|
+
<h2 className="h2" {...m.field("heading")}>{heading}</h2>
|
|
34
|
+
<div className="card-grid" {...m.field("items")}>
|
|
33
35
|
{(items ?? []).filter(Boolean).map((a, i) => (
|
|
34
36
|
<ArticleCard key={i} article={a} url={url(articlePath(a.slug, locale))} locale={locale} />
|
|
35
37
|
))}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { f, smoodly, type Props } from "smoodly";
|
|
1
|
+
import { f, smoodly, type Props, type Scope } from "smoodly";
|
|
2
2
|
|
|
3
3
|
// The site footer — a VISUAL shared item (smoodly/shared.ts): one row,
|
|
4
4
|
// edited under Shared content, rendered by the layout. Three link columns
|
|
@@ -33,12 +33,17 @@ export const footerSchema = smoodly.schema.section({
|
|
|
33
33
|
export type ContactFields = { email: string; phone?: string; address: string };
|
|
34
34
|
type Column = { title: string; links: { label: string; href: string }[] };
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
const m = smoodly.marks(footerSchema);
|
|
37
|
+
|
|
38
|
+
function FooterColumn({ column, scope }: { column: Column | undefined; scope: Scope<Column> }) {
|
|
37
39
|
if (!column) return null;
|
|
38
40
|
return (
|
|
39
|
-
<div className="site-footer__column">
|
|
40
|
-
<span className="site-footer__title">{column.title}</span>
|
|
41
|
-
{(column.links ?? []).map((l, i) =>
|
|
41
|
+
<div className="site-footer__column" {...scope.attrs}>
|
|
42
|
+
<span className="site-footer__title" {...scope.field("title")}>{column.title}</span>
|
|
43
|
+
{(column.links ?? []).map((l, i) => {
|
|
44
|
+
const link = scope.item("links", i);
|
|
45
|
+
return <a key={i} href={l.href} {...link.attrs} {...link.field("label")}>{l.label}</a>;
|
|
46
|
+
})}
|
|
42
47
|
</div>
|
|
43
48
|
);
|
|
44
49
|
}
|
|
@@ -48,7 +53,7 @@ export function FooterView({ tagline, company, legal, social, copyright, contact
|
|
|
48
53
|
<smoodly.Section as="footer" styles={styles} className="site-footer">
|
|
49
54
|
<div className="container site-footer__grid">
|
|
50
55
|
<div className="site-footer__about">
|
|
51
|
-
<p className="site-footer__tagline">{tagline}</p>
|
|
56
|
+
<p className="site-footer__tagline" {...m.field("tagline")}>{tagline}</p>
|
|
52
57
|
{contact && (
|
|
53
58
|
<address className="site-footer__contact">
|
|
54
59
|
<a href={`mailto:${contact.email}`}>{contact.email}</a>
|
|
@@ -57,11 +62,11 @@ export function FooterView({ tagline, company, legal, social, copyright, contact
|
|
|
57
62
|
</address>
|
|
58
63
|
)}
|
|
59
64
|
</div>
|
|
60
|
-
<FooterColumn column={company} />
|
|
61
|
-
<FooterColumn column={legal} />
|
|
62
|
-
<FooterColumn column={social} />
|
|
65
|
+
<FooterColumn column={company} scope={m.object("company")} />
|
|
66
|
+
<FooterColumn column={legal} scope={m.object("legal")} />
|
|
67
|
+
<FooterColumn column={social} scope={m.object("social")} />
|
|
63
68
|
</div>
|
|
64
|
-
<div className="container site-footer__copyright">{copyright}</div>
|
|
69
|
+
<div className="container site-footer__copyright" {...m.field("copyright")}>{copyright}</div>
|
|
65
70
|
</smoodly.Section>
|
|
66
71
|
);
|
|
67
72
|
}
|
|
@@ -20,14 +20,19 @@ export const headerSchema = smoodly.schema.section({
|
|
|
20
20
|
|
|
21
21
|
export type LocaleLink = { code: string; url: string; current: boolean };
|
|
22
22
|
|
|
23
|
+
const m = smoodly.marks(headerSchema);
|
|
24
|
+
|
|
23
25
|
export function HeaderView({ brand, nav, locales, styles }: Props<typeof headerSchema> & { locales?: LocaleLink[] }) {
|
|
24
26
|
const home = locales?.find((l) => l.current)?.url ?? "/";
|
|
25
27
|
return (
|
|
26
28
|
<smoodly.Section as="header" styles={styles} className="site-header">
|
|
27
29
|
<div className="container site-header__bar">
|
|
28
|
-
<a className="site-header__brand" href={home}>{brand}</a>
|
|
30
|
+
<a className="site-header__brand" href={home} {...m.field("brand")}>{brand}</a>
|
|
29
31
|
<nav className="site-header__nav">
|
|
30
|
-
{(nav ?? []).map((item, i) =>
|
|
32
|
+
{(nav ?? []).map((item, i) => {
|
|
33
|
+
const link = m.item("nav", i);
|
|
34
|
+
return <a key={i} href={item.href} {...link.attrs} {...link.field("label")}>{item.label}</a>;
|
|
35
|
+
})}
|
|
31
36
|
</nav>
|
|
32
37
|
{locales && locales.length > 1 && (
|
|
33
38
|
<nav className="site-header__locales" aria-label="Language">
|
|
@@ -24,23 +24,29 @@ export const heroSchema = smoodly.schema.section({
|
|
|
24
24
|
},
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
const m = smoodly.marks(heroSchema);
|
|
28
|
+
|
|
27
29
|
function HeroView({ kicker, heading, text, buttons, image, styles }: Props<typeof heroSchema>) {
|
|
28
30
|
return (
|
|
29
31
|
<smoodly.Section styles={styles} className="hero">
|
|
30
32
|
<div className="container hero__grid">
|
|
31
33
|
<div className="hero__copy">
|
|
32
|
-
{kicker && <p className="kicker">{kicker}</p>}
|
|
33
|
-
<h1 className="hero__heading">{heading}</h1>
|
|
34
|
-
<p className="hero__text">{text}</p>
|
|
34
|
+
{kicker && <p className="kicker" {...m.field("kicker")}>{kicker}</p>}
|
|
35
|
+
<h1 className="hero__heading" {...m.field("heading")}>{heading}</h1>
|
|
36
|
+
<p className="hero__text" {...m.field("text")}>{text}</p>
|
|
35
37
|
{buttons?.length > 0 && (
|
|
36
38
|
<div className="hero__buttons">
|
|
37
|
-
{buttons.map((b, i) =>
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
{buttons.map((b, i) => {
|
|
40
|
+
const button = m.item("buttons", i);
|
|
41
|
+
return (
|
|
42
|
+
<a key={i} className={i === 0 ? "button" : "button button--outline"} href={b.href}
|
|
43
|
+
{...button.attrs} {...button.field("label")}>{b.label}</a>
|
|
44
|
+
);
|
|
45
|
+
})}
|
|
40
46
|
</div>
|
|
41
47
|
)}
|
|
42
48
|
</div>
|
|
43
|
-
{image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority />}
|
|
49
|
+
{image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
|
|
44
50
|
</div>
|
|
45
51
|
</smoodly.Section>
|
|
46
52
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { f,
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
import { RichText } from "@/smoodly/richtext";
|
|
2
3
|
import { SmoodlyImage } from "smoodly/image";
|
|
3
4
|
|
|
4
5
|
// A photo beside a heading and richtext, the photo on either side (a
|
|
@@ -19,7 +20,7 @@ export const imageTextSchema = smoodly.schema.section({
|
|
|
19
20
|
link: f.object({ label: f.text(), href: f.link() }).optional(),
|
|
20
21
|
},
|
|
21
22
|
styles: {
|
|
22
|
-
imageSide: f.select(["left", "right"]).default("left"),
|
|
23
|
+
imageSide: f.select([{ value: "left", label: "Image left" }, { value: "right", label: "Image right" }]).default("left"),
|
|
23
24
|
},
|
|
24
25
|
sample: {
|
|
25
26
|
heading: "Duis aute irure dolor in reprehenderit",
|
|
@@ -30,11 +31,14 @@ export const imageTextSchema = smoodly.schema.section({
|
|
|
30
31
|
},
|
|
31
32
|
});
|
|
32
33
|
|
|
34
|
+
const m = smoodly.marks(imageTextSchema);
|
|
35
|
+
|
|
33
36
|
function ImageTextView({ image, heading, text, link, styles }: Props<typeof imageTextSchema>) {
|
|
37
|
+
const linkMarks = m.object("link");
|
|
34
38
|
const body = (
|
|
35
39
|
<>
|
|
36
|
-
<div className="prose"><RichText doc={text} /></div>
|
|
37
|
-
{link?.href && <a className="link-accent" href={link.href}>{link.label}</a>}
|
|
40
|
+
<div className="prose" {...m.field("text")}><RichText doc={text} /></div>
|
|
41
|
+
{link?.href && <a className="link-accent" href={link.href} {...linkMarks.attrs} {...linkMarks.field("label")}>{link.label}</a>}
|
|
38
42
|
</>
|
|
39
43
|
);
|
|
40
44
|
return (
|
|
@@ -42,15 +46,15 @@ function ImageTextView({ image, heading, text, link, styles }: Props<typeof imag
|
|
|
42
46
|
<div className="container image-text__grid">
|
|
43
47
|
{image ? (
|
|
44
48
|
<>
|
|
45
|
-
<SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" />
|
|
49
|
+
<SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" {...m.field("image")} />
|
|
46
50
|
<div className="image-text__copy">
|
|
47
|
-
<h2 className="h2">{heading}</h2>
|
|
51
|
+
<h2 className="h2" {...m.field("heading")}>{heading}</h2>
|
|
48
52
|
{body}
|
|
49
53
|
</div>
|
|
50
54
|
</>
|
|
51
55
|
) : (
|
|
52
56
|
<>
|
|
53
|
-
<h2 className="h2">{heading}</h2>
|
|
57
|
+
<h2 className="h2" {...m.field("heading")}>{heading}</h2>
|
|
54
58
|
<div className="image-text__copy">{body}</div>
|
|
55
59
|
</>
|
|
56
60
|
)}
|
|
@@ -21,16 +21,18 @@ export const pageHeroSchema = smoodly.schema.section({
|
|
|
21
21
|
},
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
const m = smoodly.marks(pageHeroSchema);
|
|
25
|
+
|
|
24
26
|
function PageHeroView({ kicker, heading, text, image, styles }: Props<typeof pageHeroSchema>) {
|
|
25
27
|
return (
|
|
26
28
|
<smoodly.Section styles={styles} className="page-hero">
|
|
27
29
|
<div className="container page-hero__inner">
|
|
28
30
|
<div className="page-hero__copy">
|
|
29
|
-
{kicker && <p className="kicker">{kicker}</p>}
|
|
30
|
-
<h1 className="page-hero__heading">{heading}</h1>
|
|
31
|
-
<p className="page-hero__text">{text}</p>
|
|
31
|
+
{kicker && <p className="kicker" {...m.field("kicker")}>{kicker}</p>}
|
|
32
|
+
<h1 className="page-hero__heading" {...m.field("heading")}>{heading}</h1>
|
|
33
|
+
<p className="page-hero__text" {...m.field("text")}>{text}</p>
|
|
32
34
|
</div>
|
|
33
|
-
{image && <SmoodlyImage image={image} className="page-hero__image" sizes="100vw" priority />}
|
|
35
|
+
{image && <SmoodlyImage image={image} className="page-hero__image" sizes="100vw" priority {...m.field("image")} />}
|
|
34
36
|
</div>
|
|
35
37
|
</smoodly.Section>
|
|
36
38
|
);
|