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
package/package.json
CHANGED
|
@@ -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; }
|
|
@@ -2,8 +2,7 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { Instrument_Sans, Newsreader } from "next/font/google";
|
|
3
3
|
import { HeaderView } from "@/components/sections/Header";
|
|
4
4
|
import { FooterView } from "@/components/sections/Footer";
|
|
5
|
-
import {
|
|
6
|
-
import { getSmoodlyShared } from "@/smoodly/server";
|
|
5
|
+
import { q } from "@/smoodly/server";
|
|
7
6
|
import "./globals.css";
|
|
8
7
|
|
|
9
8
|
// The starter's two faces, as CSS variables globals.css reads.
|
|
@@ -15,13 +14,13 @@ export const metadata = { title: "Lorem Co." };
|
|
|
15
14
|
// The site's root layout. The admin has its own under app/(smoodly)/admin —
|
|
16
15
|
// two root layouts, a full page load between them, which is fine: they
|
|
17
16
|
// are separate apps in spirit. Header, footer and the contact block are
|
|
18
|
-
// shared content:
|
|
17
|
+
// shared content: read through `q`, cached under their tag, null until
|
|
19
18
|
// published.
|
|
20
19
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
21
20
|
const [headerProps, footerProps, contactFields] = await Promise.all([
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
q().getShared("header"),
|
|
22
|
+
q().getShared("footer"),
|
|
23
|
+
q().getShared("contact"),
|
|
25
24
|
]);
|
|
26
25
|
return (
|
|
27
26
|
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
|
|
@@ -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
|
);
|
|
@@ -14,12 +14,14 @@ export const peopleSchema = smoodly.schema.section({
|
|
|
14
14
|
sample: { heading: "Our people", people: [] },
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
const m = smoodly.marks(peopleSchema);
|
|
18
|
+
|
|
17
19
|
function PeopleView({ heading, people: persons, styles }: Props<typeof peopleSchema>) {
|
|
18
20
|
return (
|
|
19
21
|
<smoodly.Section styles={styles} className="people">
|
|
20
22
|
<div className="container">
|
|
21
|
-
<h2 className="h2">{heading}</h2>
|
|
22
|
-
<div className="people__grid">
|
|
23
|
+
<h2 className="h2" {...m.field("heading")}>{heading}</h2>
|
|
24
|
+
<div className="people__grid" {...m.field("people")}>
|
|
23
25
|
{(persons ?? []).filter(Boolean).map((person, i) => (
|
|
24
26
|
<div key={i} className="person">
|
|
25
27
|
<span className="person__mark" aria-hidden="true">{person.name?.charAt(0)}</span>
|
|
@@ -9,7 +9,7 @@ export const pointsSchema = smoodly.schema.section({
|
|
|
9
9
|
fields: {
|
|
10
10
|
heading: f.text(),
|
|
11
11
|
points: f.list(f.object({
|
|
12
|
-
icon: f.select(["circle", "square", "diamond"]),
|
|
12
|
+
icon: f.select([{ value: "circle", label: "Circle" }, { value: "square", label: "Square" }, { value: "diamond", label: "Diamond" }]),
|
|
13
13
|
heading: f.text(),
|
|
14
14
|
text: f.text(),
|
|
15
15
|
})).min(1).max(6),
|
|
@@ -26,21 +26,26 @@ const ICONS = {
|
|
|
26
26
|
diamond: <polygon points="20,2 38,20 20,38 2,20" />,
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
const m = smoodly.marks(pointsSchema);
|
|
30
|
+
|
|
29
31
|
function PointsView({ heading, points, styles }: Props<typeof pointsSchema>) {
|
|
30
32
|
return (
|
|
31
33
|
<smoodly.Section styles={styles} className="points">
|
|
32
34
|
<div className="container">
|
|
33
|
-
<h2 className="h2">{heading}</h2>
|
|
35
|
+
<h2 className="h2" {...m.field("heading")}>{heading}</h2>
|
|
34
36
|
<div className="points__grid">
|
|
35
|
-
{(points ?? []).map((point, i) =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
{(points ?? []).map((point, i) => {
|
|
38
|
+
const p = m.item("points", i);
|
|
39
|
+
return (
|
|
40
|
+
<div key={i} className="point" {...p.attrs}>
|
|
41
|
+
<svg className="point__icon" width="40" height="40" viewBox="0 0 40 40" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" {...p.field("icon")}>
|
|
42
|
+
{ICONS[point.icon] ?? ICONS.circle}
|
|
43
|
+
</svg>
|
|
44
|
+
<h3 className="point__heading" {...p.field("heading")}>{point.heading}</h3>
|
|
45
|
+
<p className="point__text" {...p.field("text")}>{point.text}</p>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
44
49
|
</div>
|
|
45
50
|
</div>
|
|
46
51
|
</smoodly.Section>
|
|
@@ -19,12 +19,14 @@ export const quoteSchema = smoodly.schema.section({
|
|
|
19
19
|
},
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
const m = smoodly.marks(quoteSchema);
|
|
23
|
+
|
|
22
24
|
function QuoteView({ text, attribution, styles }: Props<typeof quoteSchema>) {
|
|
23
25
|
return (
|
|
24
26
|
<smoodly.Section styles={styles} className="quote">
|
|
25
27
|
<div className="quote__inner">
|
|
26
|
-
<blockquote className="quote__text"
|
|
27
|
-
<p className="quote__attribution">{attribution}</p>
|
|
28
|
+
<blockquote className="quote__text">“<span {...m.field("text")}>{text}</span>”</blockquote>
|
|
29
|
+
<p className="quote__attribution" {...m.field("attribution")}>{attribution}</p>
|
|
28
30
|
</div>
|
|
29
31
|
</smoodly.Section>
|
|
30
32
|
);
|
|
@@ -45,6 +45,12 @@ const p = (s: string): Node => ({ type: "paragraph", content: text(s) });
|
|
|
45
45
|
const h2 = (s: string): Node => ({ type: "heading", attrs: { level: 2 }, content: text(s) });
|
|
46
46
|
const quoteBlock = (s: string): Node => ({ type: "blockquote", content: [p(s)] });
|
|
47
47
|
const bullets = (...items: string[]): Node => ({ type: "bulletList", content: items.map((s) => ({ type: "listItem", content: [p(s)] })) });
|
|
48
|
+
// Block nodes (spec 2026-09-08): a library image, and the starter's two elements.
|
|
49
|
+
const imageBlock = (image: { asset: string }, caption: string): Node => ({ type: "image", attrs: { image, caption } });
|
|
50
|
+
const callout = (title: string, text: string, tone: "info" | "success" | "warning" = "info"): Node =>
|
|
51
|
+
({ type: "element", attrs: { name: "callout", fields: { title, body: doc(p(text)) }, styles: { tone } } });
|
|
52
|
+
const cta = (heading: string, text: string, label: string, href: string): Node =>
|
|
53
|
+
({ type: "element", attrs: { name: "cta", fields: { heading, text, label, href }, styles: { variant: "primary" } } });
|
|
48
54
|
const doc = (...content: Node[]) => ({ type: "doc", content });
|
|
49
55
|
|
|
50
56
|
// The starter's photos become assets: each file is uploaded once through
|
|
@@ -97,7 +103,7 @@ async function seedEntry(collection: string, en: Fields, fi?: Fields) {
|
|
|
97
103
|
return row;
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
type Tree = { zones: Record<string, Node[]> };
|
|
106
|
+
type Tree = { zones: Record<string, Node[]>; fields?: Record<string, unknown>; meta?: Record<string, unknown> };
|
|
101
107
|
|
|
102
108
|
/** Create a page (or a folder: `template: null`) in EN with its tree,
|
|
103
109
|
* publish it, then the FI row with its own tree — a locale's tree is
|
|
@@ -139,12 +145,15 @@ const body = doc(
|
|
|
139
145
|
p("Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."),
|
|
140
146
|
h2("Sed ut perspiciatis unde omnis"),
|
|
141
147
|
p("Iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo."),
|
|
148
|
+
imageBlock(img.home2, "Lorem ipsum dolor sit amet."),
|
|
142
149
|
quoteBlock("Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit."),
|
|
150
|
+
callout("Good to know", "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia."),
|
|
143
151
|
p("Sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit."),
|
|
144
152
|
h2("Quis autem vel eum iure"),
|
|
145
153
|
p("Reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur."),
|
|
146
154
|
bullets("At vero eos et accusamus et iusto odio", "Dignissimos ducimus qui blanditiis praesentium", "Voluptatum deleniti atque corrupti quos dolores"),
|
|
147
155
|
p("Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus."),
|
|
156
|
+
cta("Ready to talk?", "Sed ut perspiciatis unde omnis iste natus error sit voluptatem.", "Get in touch", paths(EN).about),
|
|
148
157
|
);
|
|
149
158
|
|
|
150
159
|
// Versioned (`versions: true` on the collection): every save below is a
|
|
@@ -242,6 +251,8 @@ const favourites = (heading: string): Node => ({
|
|
|
242
251
|
});
|
|
243
252
|
|
|
244
253
|
const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]; link: string; favourites: string }): Tree => ({
|
|
254
|
+
// The built-in SEO set rides the tree (spec 2026-09-10 page fields §2).
|
|
255
|
+
meta: { description: "Lorem Co. — consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore." },
|
|
245
256
|
zones: {
|
|
246
257
|
// The locked hero's one node. Pages created in the admin get theirs
|
|
247
258
|
// from the editor (fillLockedZones on load); a seeded tree brings its own.
|
|
@@ -290,7 +301,9 @@ await seedPage(
|
|
|
290
301
|
// both locales with its own slug and title.
|
|
291
302
|
const company = await seedPage({ slug: "company", title: "Company", template: null, fi: { slug: "yritys", title: "Yritys" } });
|
|
292
303
|
|
|
293
|
-
const aboutTree = (t: { kicker: string; people: string }): Tree => ({
|
|
304
|
+
const aboutTree = (t: { kicker: string; people: string; description: string }): Tree => ({
|
|
305
|
+
fields: { showInNavigation: true },
|
|
306
|
+
meta: { description: t.description },
|
|
294
307
|
zones: {
|
|
295
308
|
hero: [{
|
|
296
309
|
id: "node-about-hero",
|
|
@@ -320,11 +333,32 @@ const aboutTree = (t: { kicker: string; people: string }): Tree => ({
|
|
|
320
333
|
],
|
|
321
334
|
},
|
|
322
335
|
});
|
|
323
|
-
await seedPage(
|
|
336
|
+
const about = await seedPage(
|
|
324
337
|
{ slug: "about-us", title: "About us", template: "page", parentId: company.id, fi: { slug: "tietoa-meista", title: "Tietoa meistä" } },
|
|
325
|
-
{
|
|
338
|
+
{
|
|
339
|
+
en: aboutTree({ kicker: "About us", people: "Our people", description: "Lorem ipsum dolor sit amet — who we are and what we do." }),
|
|
340
|
+
fi: aboutTree({ kicker: "Tietoa meistä", people: "Meidän väki", description: "Lorem ipsum dolor sit amet — keitä olemme ja mitä teemme." }),
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// A page under About us, shown in its sub-navigation through the Page
|
|
345
|
+
// shape's `showInNavigation` field (spec 2026-09-10 page fields §7).
|
|
346
|
+
const teamTree = (t: { heading: string; text: string; people: string }): Tree => ({
|
|
347
|
+
fields: { showInNavigation: true },
|
|
348
|
+
meta: { description: t.text },
|
|
349
|
+
zones: {
|
|
350
|
+
hero: [{ id: "node-team-hero", type: "pageHero", fields: { kicker: t.heading, heading: t.heading, text: t.text, image: img.about } }],
|
|
351
|
+
body: [{ id: "node-team-people", type: "peopleSection", fields: { heading: t.people, people: [jane.id, john.id, anna.id, mark.id] } }],
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
await seedPage(
|
|
355
|
+
{ slug: "team", title: "Team", template: "page", parentId: about.id, fi: { slug: "tiimi", title: "Tiimi" } },
|
|
356
|
+
{
|
|
357
|
+
en: teamTree({ heading: "Team", text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.", people: "The whole team" }),
|
|
358
|
+
fi: teamTree({ heading: "Tiimi", text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.", people: "Koko tiimi" }),
|
|
359
|
+
},
|
|
326
360
|
);
|
|
327
361
|
|
|
328
362
|
console.log(
|
|
329
|
-
`Seeded Lorem Co. in "${EN}"${FI ? ` and "${FI}"` : ""}: home, the Articles index (${articlesSegment(EN)}${FI ? ` / ${articlesSegment(FI)}` : ""}), company/about-us${FI ? " (yritys/tietoa-meista)" : ""}, one article, four people, header, footer, contact and quote — all published.`,
|
|
363
|
+
`Seeded Lorem Co. in "${EN}"${FI ? ` and "${FI}"` : ""}: home, the Articles index (${articlesSegment(EN)}${FI ? ` / ${articlesSegment(FI)}` : ""}), company/about-us and its Team page${FI ? " (yritys/tietoa-meista/tiimi)" : ""}, one article, four people, header, footer, contact and quote — all published.`,
|
|
330
364
|
);
|