create-smoodly-app 0.0.16 → 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/AGENTS.md +2 -2
- package/templates/blank/README.md +6 -5
- package/templates/blank/app/(site)/layout.tsx +3 -3
- package/templates/blank/app/globals.css +19 -9
- package/templates/blank/components/LocaleNav.tsx +19 -0
- package/templates/blank/components/pages/Home.tsx +9 -3
- package/templates/blank/components/pages/Page.tsx +3 -1
- package/templates/blank/components/pages/Post.tsx +10 -6
- package/templates/blank/components/sections/Text.tsx +8 -4
- package/templates/blank/package.json +1 -1
- package/templates/blank/scripts/seed.ts +14 -2
- package/templates/blank/smoodly/config.ts +4 -11
- package/templates/blank/smoodly/{shared.ts → globals.ts} +4 -4
- package/templates/blank/smoodly/server.ts +9 -6
- package/templates/blank/supabase/migrations/00000000000000_smoodly.sql +1 -1
- package/templates/blank-intl/AGENTS.md +2 -2
- package/templates/blank-intl/README.md +7 -6
- package/templates/blank-intl/app/[locale]/layout.tsx +9 -14
- package/templates/blank-intl/app/globals.css +19 -9
- package/templates/blank-intl/components/LocaleNav.tsx +19 -0
- package/templates/blank-intl/components/pages/Home.tsx +9 -3
- package/templates/blank-intl/components/pages/Page.tsx +3 -1
- package/templates/blank-intl/components/pages/Post.tsx +10 -6
- package/templates/blank-intl/components/sections/Text.tsx +8 -4
- package/templates/blank-intl/package.json +1 -1
- package/templates/blank-intl/scripts/seed.ts +14 -2
- package/templates/blank-intl/smoodly/config.ts +4 -11
- package/templates/blank-intl/smoodly/{shared.ts → globals.ts} +4 -4
- package/templates/blank-intl/smoodly/server.ts +9 -6
- package/templates/blank-intl/supabase/migrations/00000000000000_smoodly.sql +1 -1
- package/templates/lorem/AGENTS.md +2 -2
- package/templates/lorem/README.md +5 -4
- package/templates/lorem/app/(site)/layout.tsx +9 -10
- package/templates/lorem/app/globals.css +53 -44
- package/templates/lorem/components/PageFrame.tsx +30 -0
- package/templates/lorem/components/elements/Callout.tsx +3 -3
- package/templates/lorem/components/elements/Cta.tsx +4 -4
- 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 +8 -7
- package/templates/lorem/components/pages/Page.tsx +4 -3
- package/templates/lorem/components/sections/Footer.tsx +4 -2
- package/templates/lorem/components/sections/Header.tsx +4 -2
- package/templates/lorem/components/sections/ImageText.tsx +5 -5
- package/templates/lorem/components/sections/Quote.tsx +9 -6
- package/templates/lorem/package.json +1 -1
- package/templates/lorem/scripts/seed.ts +28 -9
- package/templates/lorem/smoodly/config.ts +18 -11
- package/templates/lorem/smoodly/{shared.ts → globals.ts} +7 -7
- package/templates/lorem/smoodly/sections.ts +3 -3
- package/templates/lorem/smoodly/server.ts +9 -6
- package/templates/lorem/supabase/migrations/00000000000000_smoodly.sql +1 -1
- package/templates/lorem-intl/AGENTS.md +2 -2
- package/templates/lorem-intl/README.md +5 -4
- package/templates/lorem-intl/app/[locale]/layout.tsx +11 -13
- package/templates/lorem-intl/app/globals.css +53 -44
- package/templates/lorem-intl/components/PageFrame.tsx +30 -0
- package/templates/lorem-intl/components/elements/Callout.tsx +3 -3
- package/templates/lorem-intl/components/elements/Cta.tsx +4 -4
- 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 +8 -7
- package/templates/lorem-intl/components/pages/Page.tsx +4 -3
- package/templates/lorem-intl/components/sections/Footer.tsx +4 -2
- package/templates/lorem-intl/components/sections/Header.tsx +4 -2
- package/templates/lorem-intl/components/sections/ImageText.tsx +5 -5
- package/templates/lorem-intl/components/sections/Quote.tsx +9 -6
- package/templates/lorem-intl/package.json +1 -1
- package/templates/lorem-intl/scripts/seed.ts +28 -9
- package/templates/lorem-intl/smoodly/config.ts +18 -11
- package/templates/lorem-intl/smoodly/{shared.ts → globals.ts} +7 -7
- package/templates/lorem-intl/smoodly/sections.ts +3 -3
- package/templates/lorem-intl/smoodly/server.ts +9 -6
- package/templates/lorem-intl/supabase/migrations/00000000000000_smoodly.sql +1 -1
|
@@ -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
|
|
@@ -8,7 +9,7 @@ export const pageSchema = smoodly.schema.page({
|
|
|
8
9
|
name: "page",
|
|
9
10
|
title: "Page",
|
|
10
11
|
zones: {
|
|
11
|
-
body: z.freeform({ sections: [Text] }),
|
|
12
|
+
body: z.freeform({ sections: [Text], custom: true }),
|
|
12
13
|
},
|
|
13
14
|
meta: { seo: true },
|
|
14
15
|
});
|
|
@@ -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
|
|
|
@@ -5,8 +5,9 @@ import { text } from "@/smoodly/toolsets";
|
|
|
5
5
|
// A section: a title and a richtext body. Registered in
|
|
6
6
|
// smoodly/sections.ts and allowed in the page shapes' zones
|
|
7
7
|
// (components/pages/*.tsx). Copy this file to add another section.
|
|
8
|
-
// The section wrapper renders the root element
|
|
9
|
-
//
|
|
8
|
+
// The section wrapper renders the root element, Smoodly's styles set
|
|
9
|
+
// inline (spacing, width, align) and the node id; a setting the CSS
|
|
10
|
+
// reads — here `background` — is the view's own data attribute.
|
|
10
11
|
export const textSchema = smoodly.schema.section({
|
|
11
12
|
name: "text",
|
|
12
13
|
title: "Text",
|
|
@@ -15,6 +16,9 @@ export const textSchema = smoodly.schema.section({
|
|
|
15
16
|
title: f.text(),
|
|
16
17
|
body: f.richtext({ toolset: text }),
|
|
17
18
|
},
|
|
19
|
+
settings: {
|
|
20
|
+
background: f.radio([{ value: "paper", label: "Paper" }, { value: "ink", label: "Ink" }]).default("paper"),
|
|
21
|
+
},
|
|
18
22
|
sample: {
|
|
19
23
|
title: "Hello",
|
|
20
24
|
body: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Write something here." }] }] },
|
|
@@ -25,9 +29,9 @@ export const textSchema = smoodly.schema.section({
|
|
|
25
29
|
// select and inline-edit them on the canvas.
|
|
26
30
|
const m = smoodly.marks(textSchema);
|
|
27
31
|
|
|
28
|
-
function TextView({ title, body, styles }: Props<typeof textSchema>) {
|
|
32
|
+
function TextView({ title, body, settings, styles }: Props<typeof textSchema>) {
|
|
29
33
|
return (
|
|
30
|
-
<smoodly.Section styles={styles}>
|
|
34
|
+
<smoodly.Section styles={styles} className="text" data-background={settings.background}>
|
|
31
35
|
<div className="container prose">
|
|
32
36
|
<h2 {...m.field("title")}>{title}</h2>
|
|
33
37
|
<div {...m.field("body")}><RichText doc={body} /></div>
|
|
@@ -26,7 +26,7 @@ if (await pages.resolvePath(DEFAULT, "/")) {
|
|
|
26
26
|
// Richtext is TipTap JSON, the stored format; RichText renders it.
|
|
27
27
|
type Node = Record<string, unknown>;
|
|
28
28
|
const p = (s: string): Node => ({ type: "paragraph", content: [{ type: "text", text: s }] });
|
|
29
|
-
const button = (label: string, href: string): Node => ({ type: "element", attrs: { name: "button", fields: { label, href },
|
|
29
|
+
const button = (label: string, href: string): Node => ({ type: "element", attrs: { name: "button", fields: { label, href }, settings: {} } });
|
|
30
30
|
const doc = (...content: Node[]) => ({ type: "doc", content });
|
|
31
31
|
|
|
32
32
|
// --- the site item: name and tagline, published in every locale ------
|
|
@@ -38,7 +38,7 @@ for (const locale of OTHERS) {
|
|
|
38
38
|
await entries.setStatus("site", site.id, locale, "published");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// --- the home page: one Text section
|
|
41
|
+
// --- the home page: one Text section and one custom section ----------
|
|
42
42
|
|
|
43
43
|
const tree = {
|
|
44
44
|
meta: { description: "A new site, built with Smoodly." },
|
|
@@ -47,6 +47,7 @@ const tree = {
|
|
|
47
47
|
{
|
|
48
48
|
id: "node-hello",
|
|
49
49
|
type: "text",
|
|
50
|
+
settings: { background: "paper" },
|
|
50
51
|
fields: {
|
|
51
52
|
title: "Hello",
|
|
52
53
|
body: doc(
|
|
@@ -56,6 +57,17 @@ const tree = {
|
|
|
56
57
|
),
|
|
57
58
|
},
|
|
58
59
|
},
|
|
60
|
+
// The custom section: the editor's own rows of columns, rendered by Smoodly.
|
|
61
|
+
{
|
|
62
|
+
id: "node-custom",
|
|
63
|
+
type: "custom",
|
|
64
|
+
fields: { rows: [
|
|
65
|
+
{ spans: [6, 6], gap: "md", align: "top", reverse: false, columns: [
|
|
66
|
+
doc(p("A custom section holds rows of columns. Drag the width bar in the inspector to resize them.")),
|
|
67
|
+
doc(p("Each column is richtext. Columns stack on small screens; the developer's stylesheet needs nothing.")),
|
|
68
|
+
] },
|
|
69
|
+
] },
|
|
70
|
+
},
|
|
59
71
|
],
|
|
60
72
|
},
|
|
61
73
|
} as unknown as PageTree;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { defineConfig
|
|
1
|
+
import { defineConfig } from "smoodly";
|
|
2
2
|
import { routing } from "@/i18n/routing";
|
|
3
3
|
import { collections } from "./collections";
|
|
4
4
|
import { sections } from "./sections";
|
|
5
|
-
import {
|
|
5
|
+
import { globals } from "./globals";
|
|
6
6
|
import { elements } from "./elements";
|
|
7
7
|
import { HomePage } from "@/components/pages/Home";
|
|
8
8
|
import { StandardPage } from "@/components/pages/Page";
|
|
9
9
|
import { PostPage } from "@/components/pages/Post";
|
|
10
10
|
|
|
11
|
-
export { collections, sections,
|
|
11
|
+
export { collections, sections, globals, elements };
|
|
12
12
|
|
|
13
13
|
// The registry and the plain settings, one file. Runtime-free: nothing
|
|
14
14
|
// here imports a route file or Next, so a script and the CLI's template
|
|
@@ -20,7 +20,7 @@ export const config = defineConfig({
|
|
|
20
20
|
sections,
|
|
21
21
|
elements,
|
|
22
22
|
collections,
|
|
23
|
-
|
|
23
|
+
globals,
|
|
24
24
|
pages: [HomePage, StandardPage, PostPage],
|
|
25
25
|
},
|
|
26
26
|
locales: { default: routing.defaultLocale, supported: [...routing.locales] },
|
|
@@ -28,11 +28,4 @@ export const config = defineConfig({
|
|
|
28
28
|
homePageSlug: "home",
|
|
29
29
|
// Editors can nest pages three deep.
|
|
30
30
|
pages: { tree: { depth: 3 } },
|
|
31
|
-
// Every section gets this select beside its own styles; the section
|
|
32
|
-
// wrapper emits it as data-tone, and app/globals.css styles it.
|
|
33
|
-
styles: {
|
|
34
|
-
sections: {
|
|
35
|
-
tone: f.select(["paper", "ink"]).default("paper"),
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
31
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Global content: code-declared singletons, edited once under Globals
|
|
2
2
|
// and read anywhere. `site` is an object item — fields code consumes;
|
|
3
|
-
// the layout reads it through q().
|
|
3
|
+
// the layout reads it through q().getGlobal("site"). A visual item
|
|
4
4
|
// (`section: SomeSection`) renders through a section instead.
|
|
5
5
|
import { f, smoodly } from "smoodly";
|
|
6
6
|
|
|
7
|
-
export const site = smoodly.
|
|
7
|
+
export const site = smoodly.global({
|
|
8
8
|
name: "site",
|
|
9
9
|
title: "Site",
|
|
10
10
|
fields: {
|
|
@@ -13,4 +13,4 @@ export const site = smoodly.shared({
|
|
|
13
13
|
},
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
export const
|
|
16
|
+
export const globals = [site];
|
|
@@ -15,10 +15,10 @@ import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admi
|
|
|
15
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
|
-
import { collections, config, elements, sections,
|
|
18
|
+
import { collections, config, elements, sections, globals } from "./config";
|
|
19
19
|
|
|
20
20
|
type Renderer = ReturnType<typeof createPageRenderer>;
|
|
21
|
-
type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof
|
|
21
|
+
type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof globals>>;
|
|
22
22
|
type Bound = {
|
|
23
23
|
db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
|
|
24
24
|
};
|
|
@@ -32,9 +32,11 @@ function bound(): Bound {
|
|
|
32
32
|
// ONE path index for both stores: pages and entries share the URL space.
|
|
33
33
|
const paths = new SupabasePathIndex(db);
|
|
34
34
|
const locales = config.locales;
|
|
35
|
-
const entries = new SupabaseEntryStore(db, collections, { paths, locales,
|
|
35
|
+
const entries = new SupabaseEntryStore(db, collections, { paths, locales, globals, elements });
|
|
36
36
|
const pages = new SupabasePageStore(db, {
|
|
37
37
|
sections, elements, entries, paths, locales,
|
|
38
|
+
// The custom section's columns toolset, so a column's images count as usage (spec 2026-09-14 §6).
|
|
39
|
+
custom: config.custom,
|
|
38
40
|
homePageSlug: config.homePageSlug,
|
|
39
41
|
tree: config.pages?.tree,
|
|
40
42
|
});
|
|
@@ -47,15 +49,16 @@ function bound(): Bound {
|
|
|
47
49
|
// The site layer: (locale, path) → page or entry, and the content API
|
|
48
50
|
// `q` wraps below. Not exported — the app reads through `q`, which adds
|
|
49
51
|
// draft awareness and the cache.
|
|
50
|
-
const site = createSmoodlySite({ stores, collections,
|
|
52
|
+
const site = createSmoodlySite({ stores, collections, globals, sections, elements, locales, homePageSlug: config.homePageSlug, styles: config.styles, custom: config.custom, pages: config.registry.pages ?? [] });
|
|
51
53
|
const renderer = createPageRenderer({
|
|
52
54
|
site,
|
|
53
55
|
sections,
|
|
56
|
+
elements,
|
|
54
57
|
pages: (config.registry.pages ?? []) as PageRegistration[],
|
|
55
58
|
href,
|
|
56
59
|
});
|
|
57
60
|
// 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,
|
|
61
|
+
const q = createSmoodlyQueries({ site, collections, globals });
|
|
59
62
|
cached = { db, pages, entries, assets, auth, site, renderer, q };
|
|
60
63
|
return cached;
|
|
61
64
|
}
|
|
@@ -84,7 +87,7 @@ export function publicAuth(): AuthConfig {
|
|
|
84
87
|
export const href = (path: string, locale: string) => getPathname({ locale, href: path });
|
|
85
88
|
|
|
86
89
|
/** The read API (spec 2026-09-11) for layouts and page views that fetch:
|
|
87
|
-
* `q().getEntries("articles", { locale, limit: 3 })`, `q().
|
|
90
|
+
* `q().getEntries("articles", { locale, limit: 3 })`, `q().getGlobal("header",
|
|
88
91
|
* { locale })`, `q().getPageTree({ locale })`. Typed by the registry, the
|
|
89
92
|
* live draft in the editor preview, cached under tags the admin expires.
|
|
90
93
|
* Rename it if `q` is not to your taste — this file is yours. */
|
|
@@ -110,7 +110,7 @@ create index if not exists "entry_locales_status_idx"
|
|
|
110
110
|
-- A version belongs to ONE locale of one entry and stores the MERGED
|
|
111
111
|
-- fields (shared + localized + slug) as they were at that save, so
|
|
112
112
|
-- restoring an old Finnish version restores the shared fields too.
|
|
113
|
-
-- Rows for every collection and every
|
|
113
|
+
-- Rows for every collection and every global item (spec 2026-09-14 §1).
|
|
114
114
|
-- One row per publish plus the live draft (spec 2026-09-13 history §1).
|
|
115
115
|
create table if not exists "entry_versions" (
|
|
116
116
|
"id" uuid primary key default gen_random_uuid(),
|
|
@@ -8,8 +8,8 @@ where things are.
|
|
|
8
8
|
## Rules
|
|
9
9
|
|
|
10
10
|
- **Register everything in `smoodly/`.** Every section, element,
|
|
11
|
-
collection and
|
|
12
|
-
`elements.ts`, `collections.ts`, `
|
|
11
|
+
collection and global item is listed in its barrel (`sections.ts`,
|
|
12
|
+
`elements.ts`, `collections.ts`, `globals.ts`) and `smoodly/config.ts`
|
|
13
13
|
is the one registry. A component that is not registered does not exist
|
|
14
14
|
for the admin.
|
|
15
15
|
- **A new collection is a new migration.** Write its view with
|
|
@@ -10,9 +10,10 @@ 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/
|
|
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.
|
|
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`.
|
|
18
19
|
- `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
|
|
@@ -24,11 +25,11 @@ for an agent (or a person) building the site.
|
|
|
24
25
|
|
|
25
26
|
The Lorem Co. site the installer seeded is a tour of the features:
|
|
26
27
|
|
|
27
|
-
- **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the
|
|
28
|
+
- **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the global **quote** placed from the picker's Global group, points (a list with a minimum and a maximum, an object per item), favourite articles (an editor-picked list of refs).
|
|
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
|
-
- **
|
|
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.
|
|
18
|
-
// contact block are
|
|
19
|
-
// tag, null until published.
|
|
16
|
+
// which is fine: they are separate apps in spirit. The footer and the
|
|
17
|
+
// contact block are global content: read through `q`, cached under their
|
|
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().
|
|
23
|
-
q().
|
|
24
|
-
q().getShared("contact"),
|
|
22
|
+
const [footerProps, contactFields] = await Promise.all([
|
|
23
|
+
q().getGlobal("footer"),
|
|
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>
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/* Lorem Co. — the starter's one stylesheet.
|
|
2
2
|
Tokens from the design, base rules, then one block per section and
|
|
3
|
-
page in page order.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
page in page order. Each section carries its own padding; the editor's
|
|
4
|
+
Spacing & layout steps (Smoodly's styles set, smoodly/config.ts) render
|
|
5
|
+
inline through the section wrapper and read the --space-* / --width-*
|
|
6
|
+
variables below. A section's `background` setting is its own data
|
|
7
|
+
attribute. The site header and footer are global items rendered by
|
|
8
|
+
the layout. Photos crop with object-fit: cover at the design's aspect
|
|
9
|
+
ratios. */
|
|
7
10
|
|
|
8
11
|
:root {
|
|
12
|
+
/* Smoodly's styles set reads these (smoodly/config.ts): editor steps become padding, width, alignment. */
|
|
13
|
+
--space-sm: 24px; --space-md: 48px; --space-lg: 72px;
|
|
14
|
+
--width-narrow: 640px; --width-wide: 1440px;
|
|
15
|
+
|
|
9
16
|
--paper: #faf7f2;
|
|
10
17
|
--ink: #1f1b17;
|
|
11
18
|
--accent: #d9653a;
|
|
@@ -15,9 +22,12 @@
|
|
|
15
22
|
--body: #3a342e;
|
|
16
23
|
--radius: 6px;
|
|
17
24
|
--container: 1200px;
|
|
25
|
+
/* The custom section's rows share the container's side padding (spec 2026-09-14 §6). */
|
|
26
|
+
--smd-gutter: 32px;
|
|
18
27
|
--serif: var(--font-serif), Georgia, serif;
|
|
19
28
|
--sans: var(--font-sans), Helvetica, Arial, sans-serif;
|
|
20
29
|
}
|
|
30
|
+
@media (min-width: 768px) { :root { --space-sm: 32px; --space-md: 64px; --space-lg: 96px; } }
|
|
21
31
|
|
|
22
32
|
/* ---------- base ---------- */
|
|
23
33
|
|
|
@@ -39,7 +49,8 @@ img { display: block; max-width: 100%; }
|
|
|
39
49
|
h1, h2, h3, p, blockquote, address { margin: 0; }
|
|
40
50
|
address { font-style: normal; }
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
/* The container reads the editor's width step; without one, the design's width. */
|
|
53
|
+
.container { max-width: var(--smd-width, var(--container)); width: 100%; margin: 0 auto; padding-inline: 32px; }
|
|
43
54
|
|
|
44
55
|
.kicker { font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); font-weight: 600; }
|
|
45
56
|
.h2 { font-family: var(--serif); font-weight: 400; font-size: clamp(30px, 3.5vw, 44px); line-height: 1.1; letter-spacing: -0.02em; text-wrap: balance; }
|
|
@@ -52,48 +63,40 @@ address { font-style: normal; }
|
|
|
52
63
|
.button--outline:hover { background: transparent; color: var(--ink); border-color: var(--ink); }
|
|
53
64
|
|
|
54
65
|
/* Richtext (RichText renders bare elements) */
|
|
55
|
-
|
|
56
|
-
.prose
|
|
57
|
-
.prose
|
|
58
|
-
.prose h2 { font-
|
|
59
|
-
.prose
|
|
60
|
-
.prose
|
|
61
|
-
.prose
|
|
62
|
-
.prose
|
|
66
|
+
/* The custom section's columns (.smd-col) read like prose (spec 2026-09-14 §6). */
|
|
67
|
+
.prose, .smd-col{ display: flex; flex-direction: column; gap: 20px; font-size: 17px; line-height: 1.65; color: var(--muted); }
|
|
68
|
+
.prose p, .smd-col p{ text-wrap: pretty; }
|
|
69
|
+
.prose h2, .prose h3, .smd-col h2, .smd-col h3 { font-family: var(--serif); font-weight: 500; color: var(--ink); line-height: 1.2; letter-spacing: -0.01em; }
|
|
70
|
+
.prose h2, .smd-col h2{ font-size: 32px; margin-top: 8px; }
|
|
71
|
+
.prose h3, .smd-col h3{ font-size: 24px; }
|
|
72
|
+
.prose ul, .prose ol, .smd-col ul, .smd-col ol { margin: 0; padding-left: 22px; display: flex; flex-direction: column; gap: 8px; }
|
|
73
|
+
.prose blockquote, .smd-col blockquote{ margin: 8px 0; padding-left: 24px; border-left: 2px solid var(--accent); font-family: var(--serif); font-style: italic; font-size: 24px; line-height: 1.4; color: var(--ink); }
|
|
74
|
+
.prose a, .smd-col a{ color: var(--accent); text-decoration: underline; }
|
|
63
75
|
.prose--lead { gap: 24px; font-size: 18px; line-height: 1.7; color: var(--body); }
|
|
64
76
|
.prose--lead > p:first-child { font-family: var(--serif); font-size: 24px; line-height: 1.45; color: var(--ink); }
|
|
65
77
|
|
|
66
78
|
/* ---------- richtext block nodes: images, callout, cta ---------- */
|
|
67
|
-
.prose figure { margin: 8px 0; }
|
|
68
|
-
|
|
69
|
-
.prose
|
|
70
|
-
.prose .
|
|
71
|
-
.prose .callout
|
|
72
|
-
.prose .callout[data-tone="
|
|
73
|
-
.prose .
|
|
74
|
-
.prose .
|
|
75
|
-
.prose .
|
|
76
|
-
.prose .cta
|
|
77
|
-
.prose .
|
|
78
|
-
.prose .
|
|
79
|
-
.prose .
|
|
80
|
-
.prose .cta
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
main > section { padding-block: 56px; }
|
|
89
|
-
[data-spacing="sm"] { padding-block: 32px; }
|
|
90
|
-
[data-spacing="lg"] { padding-block: 88px; }
|
|
91
|
-
[data-tone="ink"] { background: var(--ink); color: var(--paper); }
|
|
92
|
-
[data-tone="ink"] .prose, [data-tone="ink"] .hero__text, [data-tone="ink"] .page-hero__text,
|
|
93
|
-
[data-tone="ink"] .point__text, [data-tone="ink"] .card__meta, [data-tone="ink"] .card__excerpt,
|
|
94
|
-
[data-tone="ink"] .person__role, [data-tone="ink"] .quote__attribution { color: var(--line); }
|
|
95
|
-
[data-tone="ink"] .button { background: var(--paper); color: var(--ink); }
|
|
96
|
-
[data-tone="ink"] .button--outline { background: transparent; color: var(--paper); border-color: var(--muted); }
|
|
79
|
+
.prose figure, .smd-col figure{ margin: 8px 0; }
|
|
80
|
+
/* Inline, so the section's align step moves an image with the text around it. */
|
|
81
|
+
.prose figure img, .smd-col figure img{ display: inline-block; vertical-align: top; max-width: 100%; height: auto; border-radius: var(--radius); }
|
|
82
|
+
.prose figcaption, .smd-col figcaption{ margin-top: 8px; font-size: 14px; color: var(--muted); }
|
|
83
|
+
.prose .callout, .smd-col .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; }
|
|
84
|
+
.prose .callout[data-tone="success"], .smd-col .callout[data-tone="success"]{ border-left-color: #3a8f5c; }
|
|
85
|
+
.prose .callout[data-tone="warning"], .smd-col .callout[data-tone="warning"]{ border-left-color: #c9962b; background: #fff8e8; }
|
|
86
|
+
.prose .callout__title, .smd-col .callout__title{ color: var(--ink); font-size: 15px; letter-spacing: 0.02em; }
|
|
87
|
+
.prose .callout__body, .smd-col .callout__body{ display: flex; flex-direction: column; gap: 8px; }
|
|
88
|
+
.prose .cta, .smd-col .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; }
|
|
89
|
+
.prose .cta[data-variant="outline"], .smd-col .cta[data-variant="outline"]{ background: transparent; color: var(--ink); border: 1px solid var(--line-strong); }
|
|
90
|
+
.prose .cta__heading, .smd-col .cta__heading{ font-family: var(--serif); font-size: 24px; font-weight: 500; line-height: 1.2; display: block; }
|
|
91
|
+
.prose .cta__text, .smd-col .cta__text{ margin: 6px 0 0; font-size: 15px; color: inherit; opacity: 0.8; }
|
|
92
|
+
.prose .cta .button, .smd-col .cta .button{ background: var(--paper); color: var(--ink); flex: none; }
|
|
93
|
+
.prose .cta[data-variant="outline"] .button, .smd-col .cta[data-variant="outline"] .button{ background: var(--ink); color: var(--paper); }
|
|
94
|
+
|
|
95
|
+
/* ---------- section background: the quote's own `background` setting (Paper / Ink) ---------- */
|
|
96
|
+
|
|
97
|
+
[data-background="ink"] { background: var(--ink); color: var(--paper); }
|
|
98
|
+
[data-background="ink"] .quote__attribution { color: var(--line); }
|
|
99
|
+
[data-background="ink"].quote { border-color: transparent; }
|
|
97
100
|
|
|
98
101
|
/* ---------- header ---------- */
|
|
99
102
|
|
|
@@ -105,6 +108,7 @@ main > section { padding-block: 56px; }
|
|
|
105
108
|
|
|
106
109
|
/* ---------- hero (home) ---------- */
|
|
107
110
|
|
|
111
|
+
.hero { padding-block: 56px; }
|
|
108
112
|
.hero__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 48px; align-items: center; }
|
|
109
113
|
.hero__copy { display: flex; flex-direction: column; gap: 24px; }
|
|
110
114
|
.hero__heading { font-family: var(--serif); font-weight: 400; font-size: clamp(40px, 5vw, 68px); line-height: 1.05; letter-spacing: -0.02em; text-wrap: balance; }
|
|
@@ -114,6 +118,7 @@ main > section { padding-block: 56px; }
|
|
|
114
118
|
|
|
115
119
|
/* ---------- page hero ---------- */
|
|
116
120
|
|
|
121
|
+
.page-hero { padding-block: 56px; }
|
|
117
122
|
.page-hero__inner { display: flex; flex-direction: column; gap: 40px; }
|
|
118
123
|
.page-hero__copy { display: flex; flex-direction: column; gap: 20px; max-width: 760px; }
|
|
119
124
|
.page-hero__heading { font-family: var(--serif); font-weight: 400; font-size: clamp(40px, 5vw, 64px); line-height: 1.05; letter-spacing: -0.02em; text-wrap: balance; }
|
|
@@ -122,6 +127,7 @@ main > section { padding-block: 56px; }
|
|
|
122
127
|
|
|
123
128
|
/* ---------- image and text ---------- */
|
|
124
129
|
|
|
130
|
+
.image-text { padding-block: 56px; }
|
|
125
131
|
.image-text__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 48px; align-items: center; }
|
|
126
132
|
.image-text__image { width: 100%; border-radius: var(--radius); }
|
|
127
133
|
[data-image-side="right"] .image-text__image { order: 2; }
|
|
@@ -130,13 +136,14 @@ main > section { padding-block: 56px; }
|
|
|
130
136
|
|
|
131
137
|
/* ---------- quote ---------- */
|
|
132
138
|
|
|
133
|
-
.quote { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
|
139
|
+
.quote { padding-block: 56px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
|
134
140
|
.quote__inner { max-width: 880px; margin: 0 auto; padding-inline: 32px; text-align: center; display: flex; flex-direction: column; gap: 24px; align-items: center; }
|
|
135
141
|
.quote__text { font-family: var(--serif); font-style: italic; font-weight: 300; font-size: clamp(26px, 3vw, 38px); line-height: 1.3; letter-spacing: -0.01em; text-wrap: balance; }
|
|
136
142
|
.quote__attribution { font-size: 14px; color: var(--muted); letter-spacing: 0.04em; }
|
|
137
143
|
|
|
138
144
|
/* ---------- points ---------- */
|
|
139
145
|
|
|
146
|
+
.points { padding-block: 56px; }
|
|
140
147
|
.points .container { display: flex; flex-direction: column; gap: 48px; }
|
|
141
148
|
.points__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 40px; }
|
|
142
149
|
.point { display: flex; flex-direction: column; gap: 16px; }
|
|
@@ -146,6 +153,7 @@ main > section { padding-block: 56px; }
|
|
|
146
153
|
|
|
147
154
|
/* ---------- article cards (favourites, index, related) ---------- */
|
|
148
155
|
|
|
156
|
+
.favourites { padding-block: 56px; }
|
|
149
157
|
.favourites .container, .related .container { display: flex; flex-direction: column; gap: 40px; }
|
|
150
158
|
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 40px 32px; }
|
|
151
159
|
.card-grid--3 { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
|
@@ -161,6 +169,7 @@ h3.card__title { font-size: 24px; }
|
|
|
161
169
|
|
|
162
170
|
/* ---------- people ---------- */
|
|
163
171
|
|
|
172
|
+
.people { padding-block: 56px; }
|
|
164
173
|
.people .container { display: flex; flex-direction: column; gap: 40px; }
|
|
165
174
|
.people__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 40px 32px; }
|
|
166
175
|
.person { display: flex; flex-direction: column; gap: 12px; }
|
|
@@ -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
|
+
}
|
|
@@ -14,7 +14,7 @@ export const calloutSchema = smoodly.schema.element({
|
|
|
14
14
|
title: f.text().max(80),
|
|
15
15
|
body: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
|
|
16
16
|
},
|
|
17
|
-
|
|
17
|
+
settings: {
|
|
18
18
|
tone: f.select([{ value: "info", label: "Info" }, { value: "success", label: "Success" }, { value: "warning", label: "Warning" }]).default("info"),
|
|
19
19
|
},
|
|
20
20
|
sample: {
|
|
@@ -23,9 +23,9 @@ export const calloutSchema = smoodly.schema.element({
|
|
|
23
23
|
},
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
function CalloutView({ title, body,
|
|
26
|
+
function CalloutView({ title, body, settings }: Props<typeof calloutSchema>) {
|
|
27
27
|
return (
|
|
28
|
-
<aside className="callout" data-tone={
|
|
28
|
+
<aside className="callout" data-tone={settings.tone}>
|
|
29
29
|
<strong className="callout__title">{title}</strong>
|
|
30
30
|
<div className="callout__body"><RichText doc={body} /></div>
|
|
31
31
|
</aside>
|
|
@@ -14,20 +14,20 @@ export const ctaSchema = smoodly.schema.element({
|
|
|
14
14
|
label: f.text().max(40),
|
|
15
15
|
href: f.link(),
|
|
16
16
|
},
|
|
17
|
-
|
|
17
|
+
settings: {
|
|
18
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
|
});
|
|
22
22
|
|
|
23
|
-
function CtaView({ heading, text, label, href,
|
|
23
|
+
function CtaView({ heading, text, label, href, settings }: Props<typeof ctaSchema>) {
|
|
24
24
|
return (
|
|
25
|
-
<div className="cta" data-variant={
|
|
25
|
+
<div className="cta" data-variant={settings.variant}>
|
|
26
26
|
<div className="cta__copy">
|
|
27
27
|
<strong className="cta__heading">{heading}</strong>
|
|
28
28
|
{text && <p className="cta__text">{text}</p>}
|
|
29
29
|
</div>
|
|
30
|
-
<a className={
|
|
30
|
+
<a className={settings.variant === "outline" ? "button button--outline" : "button"} href={href}>{label}</a>
|
|
31
31
|
</div>
|
|
32
32
|
);
|
|
33
33
|
}
|