create-smoodly-app 0.0.13 → 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 +4 -0
- package/templates/next/app/layout.tsx +5 -6
- package/templates/next/components/elements/Callout.tsx +1 -1
- package/templates/next/components/elements/Cta.tsx +1 -1
- package/templates/next/components/pages/Article.tsx +3 -2
- package/templates/next/components/pages/Articles.tsx +7 -7
- package/templates/next/components/pages/Page.tsx +11 -1
- package/templates/next/components/sections/ImageText.tsx +1 -1
- package/templates/next/components/sections/Points.tsx +1 -1
- package/templates/next/package.json +1 -1
- package/templates/next/scripts/seed.ts +30 -5
- package/templates/next/smoodly/server.ts +17 -13
- package/templates/next-intl/app/[locale]/layout.tsx +5 -6
- package/templates/next-intl/app/globals.css +4 -0
- package/templates/next-intl/components/elements/Callout.tsx +1 -1
- package/templates/next-intl/components/elements/Cta.tsx +1 -1
- package/templates/next-intl/components/pages/Article.tsx +3 -2
- 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/ImageText.tsx +1 -1
- package/templates/next-intl/components/sections/Points.tsx +1 -1
- package/templates/next-intl/package.json +1 -1
- package/templates/next-intl/scripts/seed.ts +30 -5
- package/templates/next-intl/smoodly/server.ts +17 -13
package/package.json
CHANGED
|
@@ -173,6 +173,10 @@ h3.card__title { font-size: 24px; }
|
|
|
173
173
|
.breadcrumb { padding-top: 24px; font-size: 14px; color: var(--muted); }
|
|
174
174
|
.breadcrumb a:hover { color: var(--accent); }
|
|
175
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
|
+
|
|
176
180
|
.articles-index { padding-bottom: 96px; }
|
|
177
181
|
|
|
178
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}`}>
|
|
@@ -15,7 +15,7 @@ export const calloutSchema = smoodly.schema.element({
|
|
|
15
15
|
body: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
|
|
16
16
|
},
|
|
17
17
|
styles: {
|
|
18
|
-
tone: f.select(["info", "success", "warning"]).default("info"),
|
|
18
|
+
tone: f.select([{ value: "info", label: "Info" }, { value: "success", label: "Success" }, { value: "warning", label: "Warning" }]).default("info"),
|
|
19
19
|
},
|
|
20
20
|
sample: {
|
|
21
21
|
title: "Good to know",
|
|
@@ -15,7 +15,7 @@ export const ctaSchema = smoodly.schema.element({
|
|
|
15
15
|
href: f.link(),
|
|
16
16
|
},
|
|
17
17
|
styles: {
|
|
18
|
-
variant: f.select(["primary", "outline"]).default("primary"),
|
|
18
|
+
variant: f.select([{ value: "primary", label: "Primary" }, { value: "outline", label: "Outline" }]).default("primary"),
|
|
19
19
|
},
|
|
20
20
|
sample: { heading: "Ready to start?", text: "Sed ut perspiciatis unde omnis iste natus.", label: "Get in touch", href: "/company/about-us" },
|
|
21
21
|
});
|
|
@@ -2,7 +2,7 @@ import { smoodly, type EntryPageProps } from "smoodly";
|
|
|
2
2
|
import { RichText } from "@/smoodly/richtext";
|
|
3
3
|
import { SmoodlyImage } from "smoodly/image";
|
|
4
4
|
import { articles } from "@/smoodly/collections";
|
|
5
|
-
import {
|
|
5
|
+
import { q } from "@/smoodly/server";
|
|
6
6
|
import { ArticleCard } from "../ArticleCard";
|
|
7
7
|
import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
|
|
8
8
|
import { ui } from "../strings";
|
|
@@ -16,7 +16,8 @@ import { ui } from "../strings";
|
|
|
16
16
|
async function ArticleView({ entry }: EntryPageProps) {
|
|
17
17
|
const { locale, t, url } = await ui();
|
|
18
18
|
const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
|
|
19
|
-
|
|
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);
|
|
20
21
|
const index = url(articlesIndexPath(locale));
|
|
21
22
|
return (
|
|
22
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
|
</>
|
|
@@ -20,7 +20,7 @@ export const imageTextSchema = smoodly.schema.section({
|
|
|
20
20
|
link: f.object({ label: f.text(), href: f.link() }).optional(),
|
|
21
21
|
},
|
|
22
22
|
styles: {
|
|
23
|
-
imageSide: f.select(["left", "right"]).default("left"),
|
|
23
|
+
imageSide: f.select([{ value: "left", label: "Image left" }, { value: "right", label: "Image right" }]).default("left"),
|
|
24
24
|
},
|
|
25
25
|
sample: {
|
|
26
26
|
heading: "Duis aute irure dolor in reprehenderit",
|
|
@@ -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),
|
|
@@ -103,7 +103,7 @@ async function seedEntry(collection: string, en: Fields, fi?: Fields) {
|
|
|
103
103
|
return row;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
type Tree = { zones: Record<string, Node[]> };
|
|
106
|
+
type Tree = { zones: Record<string, Node[]>; fields?: Record<string, unknown>; meta?: Record<string, unknown> };
|
|
107
107
|
|
|
108
108
|
/** Create a page (or a folder: `template: null`) in EN with its tree,
|
|
109
109
|
* publish it, then the FI row with its own tree — a locale's tree is
|
|
@@ -251,6 +251,8 @@ const favourites = (heading: string): Node => ({
|
|
|
251
251
|
});
|
|
252
252
|
|
|
253
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." },
|
|
254
256
|
zones: {
|
|
255
257
|
// The locked hero's one node. Pages created in the admin get theirs
|
|
256
258
|
// from the editor (fillLockedZones on load); a seeded tree brings its own.
|
|
@@ -299,7 +301,9 @@ await seedPage(
|
|
|
299
301
|
// both locales with its own slug and title.
|
|
300
302
|
const company = await seedPage({ slug: "company", title: "Company", template: null, fi: { slug: "yritys", title: "Yritys" } });
|
|
301
303
|
|
|
302
|
-
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 },
|
|
303
307
|
zones: {
|
|
304
308
|
hero: [{
|
|
305
309
|
id: "node-about-hero",
|
|
@@ -329,11 +333,32 @@ const aboutTree = (t: { kicker: string; people: string }): Tree => ({
|
|
|
329
333
|
],
|
|
330
334
|
},
|
|
331
335
|
});
|
|
332
|
-
await seedPage(
|
|
336
|
+
const about = await seedPage(
|
|
333
337
|
{ slug: "about-us", title: "About us", template: "page", parentId: company.id, fi: { slug: "tietoa-meista", title: "Tietoa meistä" } },
|
|
334
|
-
{
|
|
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
|
+
},
|
|
335
360
|
);
|
|
336
361
|
|
|
337
362
|
console.log(
|
|
338
|
-
`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.`,
|
|
339
364
|
);
|
|
@@ -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
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;
|
|
@@ -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, elements, 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);
|
|
@@ -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 (
|
|
@@ -173,6 +173,10 @@ h3.card__title { font-size: 24px; }
|
|
|
173
173
|
.breadcrumb { padding-top: 24px; font-size: 14px; color: var(--muted); }
|
|
174
174
|
.breadcrumb a:hover { color: var(--accent); }
|
|
175
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
|
+
|
|
176
180
|
.articles-index { padding-bottom: 96px; }
|
|
177
181
|
|
|
178
182
|
.article { padding-top: 48px; display: flex; flex-direction: column; gap: 40px; }
|
|
@@ -15,7 +15,7 @@ export const calloutSchema = smoodly.schema.element({
|
|
|
15
15
|
body: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
|
|
16
16
|
},
|
|
17
17
|
styles: {
|
|
18
|
-
tone: f.select(["info", "success", "warning"]).default("info"),
|
|
18
|
+
tone: f.select([{ value: "info", label: "Info" }, { value: "success", label: "Success" }, { value: "warning", label: "Warning" }]).default("info"),
|
|
19
19
|
},
|
|
20
20
|
sample: {
|
|
21
21
|
title: "Good to know",
|
|
@@ -15,7 +15,7 @@ export const ctaSchema = smoodly.schema.element({
|
|
|
15
15
|
href: f.link(),
|
|
16
16
|
},
|
|
17
17
|
styles: {
|
|
18
|
-
variant: f.select(["primary", "outline"]).default("primary"),
|
|
18
|
+
variant: f.select([{ value: "primary", label: "Primary" }, { value: "outline", label: "Outline" }]).default("primary"),
|
|
19
19
|
},
|
|
20
20
|
sample: { heading: "Ready to start?", text: "Sed ut perspiciatis unde omnis iste natus.", label: "Get in touch", href: "/company/about-us" },
|
|
21
21
|
});
|
|
@@ -2,7 +2,7 @@ import { smoodly, type EntryPageProps } from "smoodly";
|
|
|
2
2
|
import { RichText } from "@/smoodly/richtext";
|
|
3
3
|
import { SmoodlyImage } from "smoodly/image";
|
|
4
4
|
import { articles } from "@/smoodly/collections";
|
|
5
|
-
import {
|
|
5
|
+
import { q } from "@/smoodly/server";
|
|
6
6
|
import { ArticleCard } from "../ArticleCard";
|
|
7
7
|
import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
|
|
8
8
|
import { ui } from "../strings";
|
|
@@ -16,7 +16,8 @@ import { ui } from "../strings";
|
|
|
16
16
|
async function ArticleView({ entry }: EntryPageProps) {
|
|
17
17
|
const { locale, t, url } = await ui();
|
|
18
18
|
const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
|
|
19
|
-
|
|
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);
|
|
20
21
|
const index = url(articlesIndexPath(locale));
|
|
21
22
|
return (
|
|
22
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
|
</>
|
|
@@ -20,7 +20,7 @@ export const imageTextSchema = smoodly.schema.section({
|
|
|
20
20
|
link: f.object({ label: f.text(), href: f.link() }).optional(),
|
|
21
21
|
},
|
|
22
22
|
styles: {
|
|
23
|
-
imageSide: f.select(["left", "right"]).default("left"),
|
|
23
|
+
imageSide: f.select([{ value: "left", label: "Image left" }, { value: "right", label: "Image right" }]).default("left"),
|
|
24
24
|
},
|
|
25
25
|
sample: {
|
|
26
26
|
heading: "Duis aute irure dolor in reprehenderit",
|
|
@@ -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),
|
|
@@ -103,7 +103,7 @@ async function seedEntry(collection: string, en: Fields, fi?: Fields) {
|
|
|
103
103
|
return row;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
type Tree = { zones: Record<string, Node[]> };
|
|
106
|
+
type Tree = { zones: Record<string, Node[]>; fields?: Record<string, unknown>; meta?: Record<string, unknown> };
|
|
107
107
|
|
|
108
108
|
/** Create a page (or a folder: `template: null`) in EN with its tree,
|
|
109
109
|
* publish it, then the FI row with its own tree — a locale's tree is
|
|
@@ -251,6 +251,8 @@ const favourites = (heading: string): Node => ({
|
|
|
251
251
|
});
|
|
252
252
|
|
|
253
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." },
|
|
254
256
|
zones: {
|
|
255
257
|
// The locked hero's one node. Pages created in the admin get theirs
|
|
256
258
|
// from the editor (fillLockedZones on load); a seeded tree brings its own.
|
|
@@ -299,7 +301,9 @@ await seedPage(
|
|
|
299
301
|
// both locales with its own slug and title.
|
|
300
302
|
const company = await seedPage({ slug: "company", title: "Company", template: null, fi: { slug: "yritys", title: "Yritys" } });
|
|
301
303
|
|
|
302
|
-
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 },
|
|
303
307
|
zones: {
|
|
304
308
|
hero: [{
|
|
305
309
|
id: "node-about-hero",
|
|
@@ -329,11 +333,32 @@ const aboutTree = (t: { kicker: string; people: string }): Tree => ({
|
|
|
329
333
|
],
|
|
330
334
|
},
|
|
331
335
|
});
|
|
332
|
-
await seedPage(
|
|
336
|
+
const about = await seedPage(
|
|
333
337
|
{ slug: "about-us", title: "About us", template: "page", parentId: company.id, fi: { slug: "tietoa-meista", title: "Tietoa meistä" } },
|
|
334
|
-
{
|
|
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
|
+
},
|
|
335
360
|
);
|
|
336
361
|
|
|
337
362
|
console.log(
|
|
338
|
-
`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.`,
|
|
339
364
|
);
|
|
@@ -4,22 +4,23 @@
|
|
|
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
17
|
import { getPathname } from "@/i18n/navigation";
|
|
18
18
|
import { collections, config, elements, sections, shared } from "./config";
|
|
19
19
|
|
|
20
20
|
type Renderer = ReturnType<typeof createPageRenderer>;
|
|
21
|
+
type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof shared>>;
|
|
21
22
|
type Bound = {
|
|
22
|
-
db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer;
|
|
23
|
+
db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
let cached: Bound | null = null;
|
|
@@ -44,17 +45,18 @@ function bound(): Bound {
|
|
|
44
45
|
// takes this local closure, not the export, so it never re-enters `bound()`.
|
|
45
46
|
const stores = () => ({ pages, entries, assets });
|
|
46
47
|
// The site layer: (locale, path) → page or entry, and the content API
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
const site = createSmoodlySite({ stores, collections, shared, sections, elements, locales, homePageSlug: config.homePageSlug });
|
|
48
|
+
// `q` wraps below. Not exported — the app reads through `q`, which adds
|
|
49
|
+
// draft awareness and the cache.
|
|
50
|
+
const site = createSmoodlySite({ stores, collections, shared, sections, elements, locales, homePageSlug: config.homePageSlug, pages: config.registry.pages ?? [] });
|
|
51
51
|
const renderer = createPageRenderer({
|
|
52
52
|
site,
|
|
53
53
|
sections,
|
|
54
54
|
pages: (config.registry.pages ?? []) as PageRegistration[],
|
|
55
55
|
href,
|
|
56
56
|
});
|
|
57
|
-
|
|
57
|
+
// The read API (spec 2026-09-11): typed by the registry, draft-aware, cached under tags the admin expires.
|
|
58
|
+
const q = createSmoodlyQueries({ site, collections, shared });
|
|
59
|
+
cached = { db, pages, entries, assets, auth, site, renderer, q };
|
|
58
60
|
return cached;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -81,13 +83,15 @@ export function publicAuth(): AuthConfig {
|
|
|
81
83
|
* becomes getPathname, which adds the locale prefix. */
|
|
82
84
|
export const href = (path: string, locale: string) => getPathname({ locale, href: path });
|
|
83
85
|
|
|
84
|
-
/** The
|
|
85
|
-
*
|
|
86
|
-
|
|
86
|
+
/** The read API (spec 2026-09-11) for layouts and page views that fetch:
|
|
87
|
+
* `q().getEntries("articles", { locale, limit: 3 })`, `q().getShared("header",
|
|
88
|
+
* { locale })`, `q().getPageTree({ locale })`. Typed by the registry, the
|
|
89
|
+
* live draft in the editor preview, cached under tags the admin expires.
|
|
90
|
+
* Rename it if `q` is not to your taste — this file is yours. */
|
|
91
|
+
export const q = (): Queries => bound().q;
|
|
87
92
|
|
|
88
93
|
/** The two route files call these. */
|
|
89
94
|
export const renderSmoodlyPath: Renderer["renderSmoodlyPath"] = (...args) => bound().renderer.renderSmoodlyPath(...args);
|
|
90
95
|
export const smoodlyMetadata: Renderer["smoodlyMetadata"] = (...args) => bound().renderer.smoodlyMetadata(...args);
|
|
91
96
|
export const renderSmoodlyPage: Renderer["renderSmoodlyPage"] = (...args) => bound().renderer.renderSmoodlyPage(...args);
|
|
92
97
|
export const smoodlyPageMetadata: Renderer["smoodlyPageMetadata"] = (...args) => bound().renderer.smoodlyPageMetadata(...args);
|
|
93
|
-
export const getSmoodlyShared: Renderer["getSmoodlyShared"] = (...args) => bound().renderer.getSmoodlyShared(...args);
|