create-smoodly-app 0.0.17 → 0.0.18
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/blank/README.md +1 -0
- package/templates/blank/components/LocaleNav.tsx +19 -0
- package/templates/blank/components/pages/Home.tsx +8 -2
- package/templates/blank/components/pages/Page.tsx +2 -0
- package/templates/blank/components/pages/Post.tsx +10 -6
- package/templates/blank/package.json +1 -1
- package/templates/blank-intl/README.md +2 -1
- package/templates/blank-intl/app/[locale]/layout.tsx +6 -11
- package/templates/blank-intl/components/LocaleNav.tsx +19 -0
- package/templates/blank-intl/components/pages/Home.tsx +8 -2
- package/templates/blank-intl/components/pages/Page.tsx +2 -0
- package/templates/blank-intl/components/pages/Post.tsx +10 -6
- package/templates/blank-intl/package.json +1 -1
- package/templates/lorem/README.md +3 -2
- package/templates/lorem/app/(site)/layout.tsx +6 -7
- package/templates/lorem/components/PageFrame.tsx +30 -0
- package/templates/lorem/components/pages/Article.tsx +7 -5
- package/templates/lorem/components/pages/Articles.tsx +4 -3
- package/templates/lorem/components/pages/Home.tsx +4 -3
- package/templates/lorem/components/pages/Page.tsx +3 -2
- package/templates/lorem/package.json +1 -1
- package/templates/lorem-intl/README.md +3 -2
- package/templates/lorem-intl/app/[locale]/layout.tsx +9 -11
- package/templates/lorem-intl/components/PageFrame.tsx +30 -0
- package/templates/lorem-intl/components/pages/Article.tsx +7 -5
- package/templates/lorem-intl/components/pages/Articles.tsx +4 -3
- package/templates/lorem-intl/components/pages/Home.tsx +4 -3
- package/templates/lorem-intl/components/pages/Page.tsx +3 -2
- package/templates/lorem-intl/package.json +1 -1
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ for an agent (or a person) building the site.
|
|
|
14
14
|
- `components/pages/` — the page shapes and the entry page: `Home` (fixed at /, one freeform zone), `Page` (open, editors create many), `Post` (the entry page for posts).
|
|
15
15
|
- `components/sections/Text.tsx` — the one section: a title and a richtext body. Listed in `smoodly/sections.ts`.
|
|
16
16
|
- `components/elements/Button.tsx` — the one element: a link styled as a button, embedded in richtext through `smoodly/toolsets.ts`.
|
|
17
|
+
- `components/LocaleNav.tsx` — the language links every page view renders from its `locales` map: the same page in the other language, or its home when there is no translation; nothing with one locale.
|
|
17
18
|
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (stores, site layer, renderer, the read API `q`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
|
|
18
19
|
- `scripts/seed.ts` — the home page and the site item, written through the stores. `npm run seed` is idempotent.
|
|
19
20
|
- `app/globals.css` — a reset, a container, the two tones. Replace it.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LocaleAlternate } from "smoodly";
|
|
2
|
+
import { href } from "@/smoodly/server";
|
|
3
|
+
|
|
4
|
+
// The language links, rendered by every page view from its own `locales`
|
|
5
|
+
// map (spec 2026-09-15): the same page in the other language where it
|
|
6
|
+
// exists and is published there, that language's home otherwise. A layout
|
|
7
|
+
// cannot know which page is on screen, so the links live with the page.
|
|
8
|
+
// Nothing with one locale, so the single-language scaffold shares the file.
|
|
9
|
+
export function LocaleNav({ locale, locales }: { locale: string; locales: Record<string, LocaleAlternate | null> }) {
|
|
10
|
+
const codes = Object.keys(locales);
|
|
11
|
+
if (codes.length < 2) return null;
|
|
12
|
+
return (
|
|
13
|
+
<nav className="container locales" aria-label="Language">
|
|
14
|
+
{codes.map((code) => (
|
|
15
|
+
<a key={code} href={href(locales[code]?.path ?? "/", code)} aria-current={code === locale ? "page" : undefined}>{code}</a>
|
|
16
|
+
))}
|
|
17
|
+
</nav>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
2
2
|
import { Text } from "../sections/Text";
|
|
3
|
+
import { LocaleNav } from "../LocaleNav";
|
|
3
4
|
|
|
4
5
|
// A page shape: zones declared, <Zone /> placed, one file. `slug` makes
|
|
5
6
|
// it fixed — one record editors can neither delete nor duplicate — and
|
|
@@ -15,8 +16,13 @@ export const homeSchema = smoodly.schema.page({
|
|
|
15
16
|
meta: { seo: true },
|
|
16
17
|
});
|
|
17
18
|
|
|
18
|
-
function HomeView({ zones }: Props<typeof homeSchema>) {
|
|
19
|
-
return
|
|
19
|
+
function HomeView({ zones, page }: Props<typeof homeSchema>) {
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<LocaleNav locale={page.locale} locales={page.locales} />
|
|
23
|
+
<Zone of={zones.body} />
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export const HomePage = smoodly.page(homeSchema, HomeView);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
2
2
|
import { Text } from "../sections/Text";
|
|
3
|
+
import { LocaleNav } from "../LocaleNav";
|
|
3
4
|
|
|
4
5
|
// An open page shape: no `slug`, so editors create as many as they like,
|
|
5
6
|
// anywhere in the tree the config allows. `page` is where this one sits
|
|
@@ -16,6 +17,7 @@ export const pageSchema = smoodly.schema.page({
|
|
|
16
17
|
function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
17
18
|
return (
|
|
18
19
|
<>
|
|
20
|
+
<LocaleNav locale={page.locale} locales={page.locales} />
|
|
19
21
|
<h1 className="container">{page.title}</h1>
|
|
20
22
|
<Zone of={zones.body} />
|
|
21
23
|
</>
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { smoodly, type EntryPageProps } from "smoodly";
|
|
2
2
|
import { RichText } from "@/smoodly/richtext";
|
|
3
3
|
import { posts } from "@/smoodly/collections";
|
|
4
|
+
import { LocaleNav } from "../LocaleNav";
|
|
4
5
|
|
|
5
6
|
// The entry page for `posts`, served at /posts/<slug>. It receives the
|
|
6
7
|
// entry's fields; the body is richtext rendered by the site's bound
|
|
7
8
|
// RichText, which knows the registered elements.
|
|
8
9
|
type PostFields = { title: string; date?: string; body?: unknown };
|
|
9
10
|
|
|
10
|
-
function PostView({ entry }: EntryPageProps) {
|
|
11
|
+
function PostView({ entry, locale, locales }: EntryPageProps) {
|
|
11
12
|
const post = entry.fields as PostFields;
|
|
12
13
|
return (
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
<>
|
|
15
|
+
<LocaleNav locale={locale} locales={locales} />
|
|
16
|
+
<article className="container prose">
|
|
17
|
+
<h1>{post.title}</h1>
|
|
18
|
+
{post.date && <p><time dateTime={post.date}>{post.date.slice(0, 10)}</time></p>}
|
|
19
|
+
<RichText doc={post.body} />
|
|
20
|
+
</article>
|
|
21
|
+
</>
|
|
18
22
|
);
|
|
19
23
|
}
|
|
20
24
|
|
|
@@ -10,11 +10,12 @@ for an agent (or a person) building the site.
|
|
|
10
10
|
|
|
11
11
|
- `app/[locale]/page.tsx` — the home page: one line calling renderSmoodlyPath([], { locale }).
|
|
12
12
|
- `app/[locale]/[...path]/page.tsx` — every other page and every collection entry, at any depth, with translated segments per locale.
|
|
13
|
-
- `app/[locale]/layout.tsx` — the site's root layout: `app/globals.css`, the
|
|
13
|
+
- `app/[locale]/layout.tsx` — the site's root layout: `app/globals.css`, the footer line from the `site` item; the admin has its own under `app/(smoodly)/admin/`.
|
|
14
14
|
- `i18n/routing.ts` — the locales and the default; `smoodly/config.ts` reads them from here. `i18n/request.ts` and `i18n/navigation.ts` are next-intl's stock files; `proxy.ts` is its locale routing (`/admin` excluded). UI strings go in `messages/<locale>.json`.
|
|
15
15
|
- `components/pages/` — the page shapes and the entry page: `Home` (fixed at /, one freeform zone), `Page` (open, editors create many), `Post` (the entry page for posts).
|
|
16
16
|
- `components/sections/Text.tsx` — the one section: a title and a richtext body. Listed in `smoodly/sections.ts`.
|
|
17
17
|
- `components/elements/Button.tsx` — the one element: a link styled as a button, embedded in richtext through `smoodly/toolsets.ts`.
|
|
18
|
+
- `components/LocaleNav.tsx` — the language links every page view renders from its `locales` map: the same page in the other language, or its home when there is no translation; nothing with one locale.
|
|
18
19
|
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (stores, site layer, renderer, the read API `q`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
|
|
19
20
|
- `scripts/seed.ts` — the home page and the site item, written through the stores. `npm run seed` is idempotent.
|
|
20
21
|
- `app/globals.css` — a reset, a container, the two tones. Replace it.
|
|
@@ -2,15 +2,18 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { notFound } from "next/navigation";
|
|
3
3
|
import { hasLocale, NextIntlClientProvider } from "next-intl";
|
|
4
4
|
import { routing } from "@/i18n/routing";
|
|
5
|
-
import {
|
|
5
|
+
import { q } from "@/smoodly/server";
|
|
6
6
|
import "../globals.css";
|
|
7
7
|
|
|
8
8
|
// The site's root layout under the locale segment; the admin has its own
|
|
9
9
|
// under app/(smoodly)/admin, outside [locale], as a second root layout.
|
|
10
10
|
// The `site` global item gives the document title and the footer line,
|
|
11
11
|
// read per locale through `q`, null until published in this locale. The
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// language links are not here: a layout cannot know which page is on
|
|
13
|
+
// screen, so every page view renders them through components/LocaleNav.tsx
|
|
14
|
+
// from its own `locales` map, landing on the same page in the other
|
|
15
|
+
// language (spec 2026-09-15). They are the visitor's only language switch,
|
|
16
|
+
// since locale detection is off (see i18n/routing.ts).
|
|
14
17
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
|
15
18
|
const { locale } = await params;
|
|
16
19
|
const site = await q().getGlobal("site", { locale });
|
|
@@ -21,18 +24,10 @@ export default async function LocaleLayout({ children, params }: { children: Rea
|
|
|
21
24
|
const { locale } = await params;
|
|
22
25
|
if (!hasLocale(routing.locales, locale)) notFound();
|
|
23
26
|
const site = await q().getGlobal("site", { locale });
|
|
24
|
-
const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
|
|
25
27
|
return (
|
|
26
28
|
<html lang={locale}>
|
|
27
29
|
<body>
|
|
28
30
|
<NextIntlClientProvider>
|
|
29
|
-
{locales.length > 1 && (
|
|
30
|
-
<nav className="container locales" aria-label="Language">
|
|
31
|
-
{locales.map((l) => (
|
|
32
|
-
<a key={l.code} href={l.url} aria-current={l.current ? "page" : undefined}>{l.code}</a>
|
|
33
|
-
))}
|
|
34
|
-
</nav>
|
|
35
|
-
)}
|
|
36
31
|
<main>{children}</main>
|
|
37
32
|
{site && <footer className="container site-footer">{site.name} · {site.tagline}</footer>}
|
|
38
33
|
</NextIntlClientProvider>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LocaleAlternate } from "smoodly";
|
|
2
|
+
import { href } from "@/smoodly/server";
|
|
3
|
+
|
|
4
|
+
// The language links, rendered by every page view from its own `locales`
|
|
5
|
+
// map (spec 2026-09-15): the same page in the other language where it
|
|
6
|
+
// exists and is published there, that language's home otherwise. A layout
|
|
7
|
+
// cannot know which page is on screen, so the links live with the page.
|
|
8
|
+
// Nothing with one locale, so the single-language scaffold shares the file.
|
|
9
|
+
export function LocaleNav({ locale, locales }: { locale: string; locales: Record<string, LocaleAlternate | null> }) {
|
|
10
|
+
const codes = Object.keys(locales);
|
|
11
|
+
if (codes.length < 2) return null;
|
|
12
|
+
return (
|
|
13
|
+
<nav className="container locales" aria-label="Language">
|
|
14
|
+
{codes.map((code) => (
|
|
15
|
+
<a key={code} href={href(locales[code]?.path ?? "/", code)} aria-current={code === locale ? "page" : undefined}>{code}</a>
|
|
16
|
+
))}
|
|
17
|
+
</nav>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
2
2
|
import { Text } from "../sections/Text";
|
|
3
|
+
import { LocaleNav } from "../LocaleNav";
|
|
3
4
|
|
|
4
5
|
// A page shape: zones declared, <Zone /> placed, one file. `slug` makes
|
|
5
6
|
// it fixed — one record editors can neither delete nor duplicate — and
|
|
@@ -15,8 +16,13 @@ export const homeSchema = smoodly.schema.page({
|
|
|
15
16
|
meta: { seo: true },
|
|
16
17
|
});
|
|
17
18
|
|
|
18
|
-
function HomeView({ zones }: Props<typeof homeSchema>) {
|
|
19
|
-
return
|
|
19
|
+
function HomeView({ zones, page }: Props<typeof homeSchema>) {
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<LocaleNav locale={page.locale} locales={page.locales} />
|
|
23
|
+
<Zone of={zones.body} />
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export const HomePage = smoodly.page(homeSchema, HomeView);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
2
2
|
import { Text } from "../sections/Text";
|
|
3
|
+
import { LocaleNav } from "../LocaleNav";
|
|
3
4
|
|
|
4
5
|
// An open page shape: no `slug`, so editors create as many as they like,
|
|
5
6
|
// anywhere in the tree the config allows. `page` is where this one sits
|
|
@@ -16,6 +17,7 @@ export const pageSchema = smoodly.schema.page({
|
|
|
16
17
|
function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
17
18
|
return (
|
|
18
19
|
<>
|
|
20
|
+
<LocaleNav locale={page.locale} locales={page.locales} />
|
|
19
21
|
<h1 className="container">{page.title}</h1>
|
|
20
22
|
<Zone of={zones.body} />
|
|
21
23
|
</>
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { smoodly, type EntryPageProps } from "smoodly";
|
|
2
2
|
import { RichText } from "@/smoodly/richtext";
|
|
3
3
|
import { posts } from "@/smoodly/collections";
|
|
4
|
+
import { LocaleNav } from "../LocaleNav";
|
|
4
5
|
|
|
5
6
|
// The entry page for `posts`, served at /posts/<slug>. It receives the
|
|
6
7
|
// entry's fields; the body is richtext rendered by the site's bound
|
|
7
8
|
// RichText, which knows the registered elements.
|
|
8
9
|
type PostFields = { title: string; date?: string; body?: unknown };
|
|
9
10
|
|
|
10
|
-
function PostView({ entry }: EntryPageProps) {
|
|
11
|
+
function PostView({ entry, locale, locales }: EntryPageProps) {
|
|
11
12
|
const post = entry.fields as PostFields;
|
|
12
13
|
return (
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
<>
|
|
15
|
+
<LocaleNav locale={locale} locales={locales} />
|
|
16
|
+
<article className="container prose">
|
|
17
|
+
<h1>{post.title}</h1>
|
|
18
|
+
{post.date && <p><time dateTime={post.date}>{post.date.slice(0, 10)}</time></p>}
|
|
19
|
+
<RichText doc={post.body} />
|
|
20
|
+
</article>
|
|
21
|
+
</>
|
|
18
22
|
);
|
|
19
23
|
}
|
|
20
24
|
|
|
@@ -10,8 +10,9 @@ for an agent (or a person) building the site.
|
|
|
10
10
|
|
|
11
11
|
- `app/(site)/page.tsx` — the home page: one line calling renderSmoodlyPath([]).
|
|
12
12
|
- `app/(site)/[...path]/page.tsx` — every other page and every collection entry, at any depth.
|
|
13
|
-
- `app/(site)/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the
|
|
13
|
+
- `app/(site)/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the footer from global content; the admin has its own under `app/(smoodly)/admin/`.
|
|
14
14
|
- `components/pages/` — the page shapes and the entry page: `Home` (locked hero, freeform body), `Articles` (a fixed page at the collection's path — its index — with a code-fetched grid), `Page` (open; About lives here), `Article` (the entry page).
|
|
15
|
+
- `components/PageFrame.tsx` — the header and `<main>` every page view renders (a layout cannot know which page is on screen); with more than one locale, the language links from the page's `locales` map — the same page in the other language, or its home when there is no translation.
|
|
15
16
|
- `components/sections/` — the building blocks editors compose with, and the header, footer and quote behind the global items. Add one, list it in `smoodly/sections.ts`.
|
|
16
17
|
- `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
|
|
17
18
|
- `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
|
|
@@ -28,7 +29,7 @@ The Lorem Co. site the installer seeded is a tour of the features:
|
|
|
28
29
|
- **Articles** — a fixed page at the collection's own URL segment, so it is the collection's index: the hero is the editor's, the grid is code fetching published articles.
|
|
29
30
|
- **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
|
|
30
31
|
- **An article** — a collection entry with a richtext body, tags (a localized list), a date and an author ref; versioned, so every save is a snapshot.
|
|
31
|
-
- **Global content** — the header
|
|
32
|
+
- **Global content** — the header every page renders through `components/PageFrame.tsx`, the footer the layout renders, the contact details the footer reads, the quote pages place.
|
|
32
33
|
|
|
33
34
|
Reseed an empty database any time with `npm run seed`.
|
|
34
35
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { Instrument_Sans, Newsreader } from "next/font/google";
|
|
3
|
-
import { HeaderView } from "@/components/sections/Header";
|
|
4
3
|
import { FooterView } from "@/components/sections/Footer";
|
|
5
4
|
import { q } from "@/smoodly/server";
|
|
6
5
|
import "../globals.css";
|
|
@@ -14,20 +13,20 @@ export const metadata = { title: "Lorem Co." };
|
|
|
14
13
|
// The site's root layout, in its own route group so the admin's layout
|
|
15
14
|
// under app/(smoodly)/admin is a second root layout beside it rather than
|
|
16
15
|
// a child of this one — two root layouts, a full page load between them,
|
|
17
|
-
// which is fine: they are separate apps in spirit.
|
|
16
|
+
// which is fine: they are separate apps in spirit. The footer and the
|
|
18
17
|
// contact block are global content: read through `q`, cached under their
|
|
19
|
-
// tag, null until published.
|
|
18
|
+
// tag, null until published. The header is NOT here: every page view
|
|
19
|
+
// renders it through components/PageFrame.tsx, the same file that carries
|
|
20
|
+
// the language links in a multilingual site (spec 2026-09-15).
|
|
20
21
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
21
|
-
const [
|
|
22
|
-
q().getGlobal("header"),
|
|
22
|
+
const [footerProps, contactFields] = await Promise.all([
|
|
23
23
|
q().getGlobal("footer"),
|
|
24
24
|
q().getGlobal("contact"),
|
|
25
25
|
]);
|
|
26
26
|
return (
|
|
27
27
|
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
|
|
28
28
|
<body>
|
|
29
|
-
{
|
|
30
|
-
<main>{children}</main>
|
|
29
|
+
{children}
|
|
31
30
|
{footerProps && <FooterView {...footerProps} contact={contactFields} />}
|
|
32
31
|
</body>
|
|
33
32
|
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { LocaleAlternate } from "smoodly";
|
|
3
|
+
import { href, q } from "@/smoodly/server";
|
|
4
|
+
import { HeaderView, type LocaleLink } from "./sections/Header";
|
|
5
|
+
|
|
6
|
+
// The header and the <main> around a page's content, rendered by every
|
|
7
|
+
// page view rather than the layout: a layout cannot know which page is on
|
|
8
|
+
// screen (Next renders the two concurrently), and the language links must
|
|
9
|
+
// land on the same page in the other language. `locales` is the page's or
|
|
10
|
+
// entry's map (spec 2026-09-15): the other locale's path where the page
|
|
11
|
+
// exists and is published there, that locale's home otherwise. With one
|
|
12
|
+
// locale (the single-language scaffold) there are no links at all. The
|
|
13
|
+
// header itself is the global item, read through `q` per locale.
|
|
14
|
+
export async function PageFrame({ locale, locales, children }: {
|
|
15
|
+
locale: string;
|
|
16
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}) {
|
|
19
|
+
const header = await q().getGlobal("header", { locale });
|
|
20
|
+
const codes = Object.keys(locales);
|
|
21
|
+
const links: LocaleLink[] | undefined = codes.length > 1
|
|
22
|
+
? codes.map((code) => ({ code, url: href(locales[code]?.path ?? "/", code), current: code === locale }))
|
|
23
|
+
: undefined;
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
{header && <HeaderView {...header} locales={links} />}
|
|
27
|
+
<main>{children}</main>
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -4,6 +4,7 @@ import { SmoodlyImage } from "smoodly/image";
|
|
|
4
4
|
import { articles } from "@/smoodly/collections";
|
|
5
5
|
import { q } from "@/smoodly/server";
|
|
6
6
|
import { ArticleCard } from "../ArticleCard";
|
|
7
|
+
import { PageFrame } from "../PageFrame";
|
|
7
8
|
import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
|
|
8
9
|
import { ui } from "../strings";
|
|
9
10
|
|
|
@@ -12,15 +13,16 @@ import { ui } from "../strings";
|
|
|
12
13
|
// resolved (author is a person). The body is richtext rendered by the
|
|
13
14
|
// package's RichText; the read time is words / 200. "Related articles"
|
|
14
15
|
// is the newest other published articles, fetched here, and absent when
|
|
15
|
-
// there are none.
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// there are none. The render locale and the alternates arrive as props
|
|
17
|
+
// (spec 2026-09-15); PageFrame renders the header from them.
|
|
18
|
+
async function ArticleView({ entry, locale, locales }: EntryPageProps) {
|
|
19
|
+
const { t, url } = await ui();
|
|
18
20
|
const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
|
|
19
21
|
// Four newest, minus this one, so three remain even when it is among them; the store applies the limit.
|
|
20
22
|
const related = (await q().getEntries("articles", { locale, limit: 4 })).filter((r) => r.entry.id !== entry.id).slice(0, 3);
|
|
21
23
|
const index = url(articlesIndexPath(locale));
|
|
22
24
|
return (
|
|
23
|
-
|
|
25
|
+
<PageFrame locale={locale} locales={locales}>
|
|
24
26
|
<article className="container article">
|
|
25
27
|
<a className="article__back" href={index}>← {t("allArticles")}</a>
|
|
26
28
|
<div className="article__head">
|
|
@@ -59,7 +61,7 @@ async function ArticleView({ entry }: EntryPageProps) {
|
|
|
59
61
|
</div>
|
|
60
62
|
</section>
|
|
61
63
|
)}
|
|
62
|
-
|
|
64
|
+
</PageFrame>
|
|
63
65
|
);
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
2
2
|
import { PageHero } from "../sections/PageHero";
|
|
3
3
|
import { ArticleCard } from "../ArticleCard";
|
|
4
|
+
import { PageFrame } from "../PageFrame";
|
|
4
5
|
import { articlePath, type ArticleFields } from "../articles";
|
|
5
6
|
import { ui } from "../strings";
|
|
6
7
|
import { q } from "@/smoodly/server";
|
|
@@ -23,11 +24,11 @@ export const articlesSchema = smoodly.schema.page({
|
|
|
23
24
|
meta: { seo: true },
|
|
24
25
|
});
|
|
25
26
|
|
|
26
|
-
async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
|
|
27
|
+
async function ArticlesView({ zones, page }: Props<typeof articlesSchema>) {
|
|
27
28
|
const { locale, url } = await ui();
|
|
28
29
|
const items = await q().getEntries("articles", { locale });
|
|
29
30
|
return (
|
|
30
|
-
|
|
31
|
+
<PageFrame locale={page.locale} locales={page.locales}>
|
|
31
32
|
<Zone of={zones.hero} />
|
|
32
33
|
<section className="container articles-index">
|
|
33
34
|
<div className="card-grid">
|
|
@@ -37,7 +38,7 @@ async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
|
|
|
37
38
|
})}
|
|
38
39
|
</div>
|
|
39
40
|
</section>
|
|
40
|
-
|
|
41
|
+
</PageFrame>
|
|
41
42
|
);
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -5,6 +5,7 @@ import { Points } from "../sections/Points";
|
|
|
5
5
|
import { FavouriteArticles } from "../sections/FavouriteArticles";
|
|
6
6
|
import { People } from "../sections/People";
|
|
7
7
|
import { Quote } from "../sections/Quote";
|
|
8
|
+
import { PageFrame } from "../PageFrame";
|
|
8
9
|
|
|
9
10
|
// A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
|
|
10
11
|
// `slug` makes it fixed — one record the editor can neither delete nor
|
|
@@ -25,12 +26,12 @@ export const homeSchema = smoodly.schema.page({
|
|
|
25
26
|
meta: { seo: true },
|
|
26
27
|
});
|
|
27
28
|
|
|
28
|
-
function HomeView({ zones }: Props<typeof homeSchema>) {
|
|
29
|
+
function HomeView({ zones, page }: Props<typeof homeSchema>) {
|
|
29
30
|
return (
|
|
30
|
-
|
|
31
|
+
<PageFrame locale={page.locale} locales={page.locales}>
|
|
31
32
|
<Zone of={zones.hero} />
|
|
32
33
|
<Zone of={zones.body} />
|
|
33
|
-
|
|
34
|
+
</PageFrame>
|
|
34
35
|
);
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -6,6 +6,7 @@ import { Points } from "../sections/Points";
|
|
|
6
6
|
import { FavouriteArticles } from "../sections/FavouriteArticles";
|
|
7
7
|
import { People } from "../sections/People";
|
|
8
8
|
import { Quote } from "../sections/Quote";
|
|
9
|
+
import { PageFrame } from "../PageFrame";
|
|
9
10
|
|
|
10
11
|
// Open registration: editors create as many of these as they like, at
|
|
11
12
|
// any depth the config allows. `page` is where this one sits in the tree.
|
|
@@ -28,7 +29,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
|
28
29
|
// Sub-navigation: the published children that opted in through their page field.
|
|
29
30
|
const shown = page.children.filter((c) => c.hasPage && c.fields.showInNavigation === true);
|
|
30
31
|
return (
|
|
31
|
-
|
|
32
|
+
<PageFrame locale={page.locale} locales={page.locales}>
|
|
32
33
|
{page.ancestors.length > 0 && (
|
|
33
34
|
// `url` is the ancestor's site URL through the app's href seam
|
|
34
35
|
// (/fi/yritys under next-intl); `path` would be the bare CMS path.
|
|
@@ -46,7 +47,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
|
46
47
|
)}
|
|
47
48
|
<Zone of={zones.hero} />
|
|
48
49
|
<Zone of={zones.body} />
|
|
49
|
-
|
|
50
|
+
</PageFrame>
|
|
50
51
|
);
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -10,9 +10,10 @@ for an agent (or a person) building the site.
|
|
|
10
10
|
|
|
11
11
|
- `app/[locale]/page.tsx` — the home page: one line calling renderSmoodlyPath([], { locale }).
|
|
12
12
|
- `app/[locale]/[...path]/page.tsx` — every other page and every collection entry, at any depth, with translated segments per locale.
|
|
13
|
-
- `app/[locale]/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the
|
|
13
|
+
- `app/[locale]/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the footer from global content; the admin has its own under `app/(smoodly)/admin/`.
|
|
14
14
|
- `i18n/routing.ts` — the locales and the default; `smoodly/config.ts` reads them from here. `i18n/request.ts` and `i18n/navigation.ts` are next-intl's stock files; `proxy.ts` is its locale routing (`/admin` excluded).
|
|
15
15
|
- `components/pages/` — the page shapes and the entry page: `Home` (locked hero, freeform body), `Articles` (a fixed page at the collection's path — its index — with a code-fetched grid), `Page` (open; About lives here), `Article` (the entry page).
|
|
16
|
+
- `components/PageFrame.tsx` — the header and `<main>` every page view renders (a layout cannot know which page is on screen); with more than one locale, the language links from the page's `locales` map — the same page in the other language, or its home when there is no translation.
|
|
16
17
|
- `components/sections/` — the building blocks editors compose with, and the header, footer and quote behind the global items. Add one, list it in `smoodly/sections.ts`.
|
|
17
18
|
- `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
|
|
18
19
|
- `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
|
|
@@ -35,7 +36,7 @@ The Lorem Co. site the installer seeded is a tour of the features:
|
|
|
35
36
|
- **Articles** — a fixed page at the collection's own URL segment, so it is the collection's index: the hero is the editor's, the grid is code fetching published articles.
|
|
36
37
|
- **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
|
|
37
38
|
- **An article** — a collection entry with a richtext body, tags (a localized list), a date and an author ref; versioned, so every save is a snapshot.
|
|
38
|
-
- **Global content** — the header
|
|
39
|
+
- **Global content** — the header every page renders through `components/PageFrame.tsx`, the footer the layout renders, the contact details the footer reads, the quote pages place.
|
|
39
40
|
|
|
40
41
|
Every page and entry exists in each locale with its own slug. Reseed an
|
|
41
42
|
empty database any time with `npm run seed`.
|
|
@@ -3,9 +3,8 @@ import { notFound } from "next/navigation";
|
|
|
3
3
|
import { Instrument_Sans, Newsreader } from "next/font/google";
|
|
4
4
|
import { hasLocale, NextIntlClientProvider } from "next-intl";
|
|
5
5
|
import { routing } from "@/i18n/routing";
|
|
6
|
-
import { HeaderView } from "@/components/sections/Header";
|
|
7
6
|
import { FooterView } from "@/components/sections/Footer";
|
|
8
|
-
import {
|
|
7
|
+
import { q } from "@/smoodly/server";
|
|
9
8
|
import "../globals.css";
|
|
10
9
|
|
|
11
10
|
// The starter's two faces, as CSS variables globals.css reads.
|
|
@@ -16,25 +15,24 @@ export const metadata = { title: "Lorem Co." };
|
|
|
16
15
|
|
|
17
16
|
// The site's root layout. The admin has its own under app/(smoodly)/admin —
|
|
18
17
|
// two root layouts, a full page load between them, which is fine: they
|
|
19
|
-
// are separate apps in spirit.
|
|
20
|
-
// global content: read through `q`, cached per locale under their tag,
|
|
21
|
-
// until published in this locale. The header
|
|
22
|
-
//
|
|
18
|
+
// are separate apps in spirit. The footer and the contact block are
|
|
19
|
+
// global content: read through `q`, cached per locale under their tag,
|
|
20
|
+
// null until published in this locale. The header is NOT here: a layout
|
|
21
|
+
// cannot know which page is on screen, and the FI / EN links must land on
|
|
22
|
+
// the same page in the other language, so every page view renders it
|
|
23
|
+
// through components/PageFrame.tsx from its own `locales` (spec 2026-09-15).
|
|
23
24
|
export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
|
|
24
25
|
const { locale } = await params;
|
|
25
26
|
if (!hasLocale(routing.locales, locale)) notFound();
|
|
26
|
-
const [
|
|
27
|
-
q().getGlobal("header", { locale }),
|
|
27
|
+
const [footerProps, contactFields] = await Promise.all([
|
|
28
28
|
q().getGlobal("footer", { locale }),
|
|
29
29
|
q().getGlobal("contact", { locale }),
|
|
30
30
|
]);
|
|
31
|
-
const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
|
|
32
31
|
return (
|
|
33
32
|
<html lang={locale} className={`${serif.variable} ${sans.variable}`}>
|
|
34
33
|
<body>
|
|
35
34
|
<NextIntlClientProvider>
|
|
36
|
-
{
|
|
37
|
-
<main>{children}</main>
|
|
35
|
+
{children}
|
|
38
36
|
{footerProps && <FooterView {...footerProps} contact={contactFields} />}
|
|
39
37
|
</NextIntlClientProvider>
|
|
40
38
|
</body>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { LocaleAlternate } from "smoodly";
|
|
3
|
+
import { href, q } from "@/smoodly/server";
|
|
4
|
+
import { HeaderView, type LocaleLink } from "./sections/Header";
|
|
5
|
+
|
|
6
|
+
// The header and the <main> around a page's content, rendered by every
|
|
7
|
+
// page view rather than the layout: a layout cannot know which page is on
|
|
8
|
+
// screen (Next renders the two concurrently), and the language links must
|
|
9
|
+
// land on the same page in the other language. `locales` is the page's or
|
|
10
|
+
// entry's map (spec 2026-09-15): the other locale's path where the page
|
|
11
|
+
// exists and is published there, that locale's home otherwise. With one
|
|
12
|
+
// locale (the single-language scaffold) there are no links at all. The
|
|
13
|
+
// header itself is the global item, read through `q` per locale.
|
|
14
|
+
export async function PageFrame({ locale, locales, children }: {
|
|
15
|
+
locale: string;
|
|
16
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}) {
|
|
19
|
+
const header = await q().getGlobal("header", { locale });
|
|
20
|
+
const codes = Object.keys(locales);
|
|
21
|
+
const links: LocaleLink[] | undefined = codes.length > 1
|
|
22
|
+
? codes.map((code) => ({ code, url: href(locales[code]?.path ?? "/", code), current: code === locale }))
|
|
23
|
+
: undefined;
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
{header && <HeaderView {...header} locales={links} />}
|
|
27
|
+
<main>{children}</main>
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -4,6 +4,7 @@ import { SmoodlyImage } from "smoodly/image";
|
|
|
4
4
|
import { articles } from "@/smoodly/collections";
|
|
5
5
|
import { q } from "@/smoodly/server";
|
|
6
6
|
import { ArticleCard } from "../ArticleCard";
|
|
7
|
+
import { PageFrame } from "../PageFrame";
|
|
7
8
|
import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
|
|
8
9
|
import { ui } from "../strings";
|
|
9
10
|
|
|
@@ -12,15 +13,16 @@ import { ui } from "../strings";
|
|
|
12
13
|
// resolved (author is a person). The body is richtext rendered by the
|
|
13
14
|
// package's RichText; the read time is words / 200. "Related articles"
|
|
14
15
|
// is the newest other published articles, fetched here, and absent when
|
|
15
|
-
// there are none.
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// there are none. The render locale and the alternates arrive as props
|
|
17
|
+
// (spec 2026-09-15); PageFrame renders the header from them.
|
|
18
|
+
async function ArticleView({ entry, locale, locales }: EntryPageProps) {
|
|
19
|
+
const { t, url } = await ui();
|
|
18
20
|
const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
|
|
19
21
|
// Four newest, minus this one, so three remain even when it is among them; the store applies the limit.
|
|
20
22
|
const related = (await q().getEntries("articles", { locale, limit: 4 })).filter((r) => r.entry.id !== entry.id).slice(0, 3);
|
|
21
23
|
const index = url(articlesIndexPath(locale));
|
|
22
24
|
return (
|
|
23
|
-
|
|
25
|
+
<PageFrame locale={locale} locales={locales}>
|
|
24
26
|
<article className="container article">
|
|
25
27
|
<a className="article__back" href={index}>← {t("allArticles")}</a>
|
|
26
28
|
<div className="article__head">
|
|
@@ -59,7 +61,7 @@ async function ArticleView({ entry }: EntryPageProps) {
|
|
|
59
61
|
</div>
|
|
60
62
|
</section>
|
|
61
63
|
)}
|
|
62
|
-
|
|
64
|
+
</PageFrame>
|
|
63
65
|
);
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
2
2
|
import { PageHero } from "../sections/PageHero";
|
|
3
3
|
import { ArticleCard } from "../ArticleCard";
|
|
4
|
+
import { PageFrame } from "../PageFrame";
|
|
4
5
|
import { articlePath, type ArticleFields } from "../articles";
|
|
5
6
|
import { ui } from "../strings";
|
|
6
7
|
import { q } from "@/smoodly/server";
|
|
@@ -23,11 +24,11 @@ export const articlesSchema = smoodly.schema.page({
|
|
|
23
24
|
meta: { seo: true },
|
|
24
25
|
});
|
|
25
26
|
|
|
26
|
-
async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
|
|
27
|
+
async function ArticlesView({ zones, page }: Props<typeof articlesSchema>) {
|
|
27
28
|
const { locale, url } = await ui();
|
|
28
29
|
const items = await q().getEntries("articles", { locale });
|
|
29
30
|
return (
|
|
30
|
-
|
|
31
|
+
<PageFrame locale={page.locale} locales={page.locales}>
|
|
31
32
|
<Zone of={zones.hero} />
|
|
32
33
|
<section className="container articles-index">
|
|
33
34
|
<div className="card-grid">
|
|
@@ -37,7 +38,7 @@ async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
|
|
|
37
38
|
})}
|
|
38
39
|
</div>
|
|
39
40
|
</section>
|
|
40
|
-
|
|
41
|
+
</PageFrame>
|
|
41
42
|
);
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -5,6 +5,7 @@ import { Points } from "../sections/Points";
|
|
|
5
5
|
import { FavouriteArticles } from "../sections/FavouriteArticles";
|
|
6
6
|
import { People } from "../sections/People";
|
|
7
7
|
import { Quote } from "../sections/Quote";
|
|
8
|
+
import { PageFrame } from "../PageFrame";
|
|
8
9
|
|
|
9
10
|
// A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
|
|
10
11
|
// `slug` makes it fixed — one record the editor can neither delete nor
|
|
@@ -25,12 +26,12 @@ export const homeSchema = smoodly.schema.page({
|
|
|
25
26
|
meta: { seo: true },
|
|
26
27
|
});
|
|
27
28
|
|
|
28
|
-
function HomeView({ zones }: Props<typeof homeSchema>) {
|
|
29
|
+
function HomeView({ zones, page }: Props<typeof homeSchema>) {
|
|
29
30
|
return (
|
|
30
|
-
|
|
31
|
+
<PageFrame locale={page.locale} locales={page.locales}>
|
|
31
32
|
<Zone of={zones.hero} />
|
|
32
33
|
<Zone of={zones.body} />
|
|
33
|
-
|
|
34
|
+
</PageFrame>
|
|
34
35
|
);
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -6,6 +6,7 @@ import { Points } from "../sections/Points";
|
|
|
6
6
|
import { FavouriteArticles } from "../sections/FavouriteArticles";
|
|
7
7
|
import { People } from "../sections/People";
|
|
8
8
|
import { Quote } from "../sections/Quote";
|
|
9
|
+
import { PageFrame } from "../PageFrame";
|
|
9
10
|
|
|
10
11
|
// Open registration: editors create as many of these as they like, at
|
|
11
12
|
// any depth the config allows. `page` is where this one sits in the tree.
|
|
@@ -28,7 +29,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
|
28
29
|
// Sub-navigation: the published children that opted in through their page field.
|
|
29
30
|
const shown = page.children.filter((c) => c.hasPage && c.fields.showInNavigation === true);
|
|
30
31
|
return (
|
|
31
|
-
|
|
32
|
+
<PageFrame locale={page.locale} locales={page.locales}>
|
|
32
33
|
{page.ancestors.length > 0 && (
|
|
33
34
|
// `url` is the ancestor's site URL through the app's href seam
|
|
34
35
|
// (/fi/yritys under next-intl); `path` would be the bare CMS path.
|
|
@@ -46,7 +47,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
|
46
47
|
)}
|
|
47
48
|
<Zone of={zones.hero} />
|
|
48
49
|
<Zone of={zones.body} />
|
|
49
|
-
|
|
50
|
+
</PageFrame>
|
|
50
51
|
);
|
|
51
52
|
}
|
|
52
53
|
|