create-smoodly-app 0.0.15 → 0.0.17
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 +5 -5
- package/templates/blank/app/(site)/layout.tsx +3 -3
- package/templates/blank/app/globals.css +19 -9
- package/templates/blank/components/pages/Home.tsx +1 -1
- package/templates/blank/components/pages/Page.tsx +1 -1
- 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/collections.ts +1 -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 +15 -12
- package/templates/blank-intl/AGENTS.md +2 -2
- package/templates/blank-intl/README.md +5 -5
- package/templates/blank-intl/app/[locale]/layout.tsx +3 -3
- package/templates/blank-intl/app/globals.css +19 -9
- package/templates/blank-intl/components/pages/Home.tsx +1 -1
- package/templates/blank-intl/components/pages/Page.tsx +1 -1
- 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/collections.ts +1 -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 +15 -12
- package/templates/lorem/AGENTS.md +2 -2
- package/templates/lorem/README.md +4 -4
- package/templates/lorem/app/(site)/layout.tsx +4 -4
- package/templates/lorem/app/globals.css +53 -44
- package/templates/lorem/components/elements/Callout.tsx +3 -3
- package/templates/lorem/components/elements/Cta.tsx +4 -4
- package/templates/lorem/components/pages/Home.tsx +4 -4
- package/templates/lorem/components/pages/Page.tsx +1 -1
- 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 +29 -10
- package/templates/lorem/smoodly/collections.ts +2 -6
- 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 +22 -18
- package/templates/lorem-intl/AGENTS.md +2 -2
- package/templates/lorem-intl/README.md +4 -4
- package/templates/lorem-intl/app/[locale]/layout.tsx +4 -4
- package/templates/lorem-intl/app/globals.css +53 -44
- 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/Home.tsx +4 -4
- package/templates/lorem-intl/components/pages/Page.tsx +1 -1
- 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 +29 -10
- package/templates/lorem-intl/smoodly/collections.ts +2 -6
- 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 +22 -18
package/package.json
CHANGED
|
@@ -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
|
|
@@ -14,7 +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
|
-
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `
|
|
17
|
+
- `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
18
|
- `scripts/seed.ts` — the home page and the site item, written through the stores. `npm run seed` is idempotent.
|
|
19
19
|
- `app/globals.css` — a reset, a container, the two tones. Replace it.
|
|
20
20
|
- `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
|
|
@@ -48,12 +48,12 @@ Then create a migration with its view:
|
|
|
48
48
|
Save the output as `supabase/migrations/<timestamp>_people.sql` and apply
|
|
49
49
|
it with `supabase db push` (hosted) or `supabase db reset` (local).
|
|
50
50
|
|
|
51
|
-
## Add a
|
|
51
|
+
## Add a global item
|
|
52
52
|
|
|
53
|
-
Copy the `site` block in `smoodly/
|
|
54
|
-
object item (`fields`) is read in code with `q().
|
|
53
|
+
Copy the `site` block in `smoodly/globals.ts` and add it to `globals`. An
|
|
54
|
+
object item (`fields`) is read in code with `q().getGlobal("name")`; a
|
|
55
55
|
visual item (`section: SomeSection`) renders through its section and can
|
|
56
|
-
be placed on pages from the picker's
|
|
56
|
+
be placed on pages from the picker's Global group. Edit both under
|
|
57
57
|
Globals in the admin.
|
|
58
58
|
|
|
59
59
|
Reseed an empty database any time with `npm run seed`.
|
|
@@ -4,16 +4,16 @@ import "../globals.css";
|
|
|
4
4
|
|
|
5
5
|
// The site's root layout, in its own route group so the admin's layout
|
|
6
6
|
// under app/(smoodly)/admin is a second root layout beside it rather than
|
|
7
|
-
// a child of this one. The `site`
|
|
7
|
+
// a child of this one. The `site` global item (smoodly/globals.ts) gives
|
|
8
8
|
// the document title and the footer line: read through `q`, cached under
|
|
9
9
|
// its tag, null until published.
|
|
10
10
|
export async function generateMetadata() {
|
|
11
|
-
const site = await q().
|
|
11
|
+
const site = await q().getGlobal("site");
|
|
12
12
|
return { title: site?.name ?? "Smoodly site" };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
16
|
-
const site = await q().
|
|
16
|
+
const site = await q().getGlobal("site");
|
|
17
17
|
return (
|
|
18
18
|
<html lang="en">
|
|
19
19
|
<body>
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/* The blank starter's one stylesheet: a reset, a system font, a
|
|
2
|
-
container, prose defaults, the button element, and the two
|
|
3
|
-
|
|
2
|
+
container, prose defaults, the button element, and the two backgrounds
|
|
3
|
+
the Text section's setting selects. The editor's Spacing & layout
|
|
4
|
+
steps (Smoodly's styles set, smoodly/config.ts) render inline through
|
|
5
|
+
the section wrapper and read the --space-* / --width-* variables.
|
|
6
|
+
Replace freely. */
|
|
4
7
|
|
|
5
8
|
:root {
|
|
9
|
+
/* Smoodly's styles set reads these (smoodly/config.ts): editor steps become padding, width, alignment. */
|
|
10
|
+
--space-sm: 1.5rem; --space-md: 3rem; --space-lg: 4.5rem;
|
|
11
|
+
--width-narrow: 40rem; --width-wide: 90rem;
|
|
12
|
+
|
|
6
13
|
--paper: #ffffff;
|
|
7
14
|
--ink: #1a1a1a;
|
|
8
15
|
--muted: #666666;
|
|
9
16
|
--container: 720px;
|
|
17
|
+
--smd-gutter: 1rem; /* the custom section's rows share the container's side gutter */
|
|
10
18
|
}
|
|
19
|
+
@media (min-width: 768px) { :root { --space-sm: 2rem; --space-md: 4rem; --space-lg: 6rem; } }
|
|
11
20
|
|
|
12
21
|
* { box-sizing: border-box; }
|
|
13
22
|
html, body { margin: 0; padding: 0; }
|
|
@@ -23,15 +32,16 @@ body {
|
|
|
23
32
|
main { flex: 1; }
|
|
24
33
|
a { color: inherit; }
|
|
25
34
|
|
|
26
|
-
|
|
35
|
+
/* The container reads the editor's width step; without one, the design's width. */
|
|
36
|
+
.container { width: min(var(--smd-width, var(--container)), 100% - 2rem); margin-inline: auto; }
|
|
27
37
|
|
|
28
|
-
/*
|
|
29
|
-
|
|
30
|
-
[data-
|
|
31
|
-
|
|
38
|
+
/* The Text section: its own padding, and the background its setting selects. */
|
|
39
|
+
.text { padding-block: 3rem; }
|
|
40
|
+
[data-background="paper"] { background: var(--paper); color: var(--ink); }
|
|
41
|
+
[data-background="ink"] { background: var(--ink); color: var(--paper); }
|
|
32
42
|
|
|
33
|
-
.prose > * + * { margin-top: 1em; }
|
|
34
|
-
.prose h1, .prose h2, .prose h3 { line-height: 1.2; }
|
|
43
|
+
.prose > * + *, .smd-col > * + * { margin-top: 1em; } /* the custom section's columns read like prose */
|
|
44
|
+
.prose h1, .prose h2, .prose h3, .smd-col h1, .smd-col h2, .smd-col h3 { line-height: 1.2; }
|
|
35
45
|
|
|
36
46
|
/* The Button element, inside richtext. */
|
|
37
47
|
.button {
|
|
@@ -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;
|
|
@@ -4,7 +4,7 @@ import { text } from "./toolsets";
|
|
|
4
4
|
// A collection: structured content with its own admin list, served at
|
|
5
5
|
// `path` by its entry page (components/pages/Post.tsx). Fields marked
|
|
6
6
|
// .localized() are stored per locale; the rest are shared across
|
|
7
|
-
// locales.
|
|
7
|
+
// locales. To add a
|
|
8
8
|
// collection: copy this block, add it to `collections`, write its view
|
|
9
9
|
// into a new migration with `collectionSQL` from "smoodly", and apply
|
|
10
10
|
// it (supabase db push, or supabase db reset locally).
|
|
@@ -14,7 +14,6 @@ export const posts = smoodly.collection({
|
|
|
14
14
|
titleField: "title",
|
|
15
15
|
path: "posts",
|
|
16
16
|
order: { by: "date", direction: "desc" },
|
|
17
|
-
versions: true,
|
|
18
17
|
fields: {
|
|
19
18
|
title: f.text().localized(),
|
|
20
19
|
slug: f.slug({ from: "title" }),
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { defineConfig
|
|
1
|
+
import { defineConfig } from "smoodly";
|
|
2
2
|
import { collections } from "./collections";
|
|
3
3
|
import { sections } from "./sections";
|
|
4
|
-
import {
|
|
4
|
+
import { globals } from "./globals";
|
|
5
5
|
import { elements } from "./elements";
|
|
6
6
|
import { HomePage } from "@/components/pages/Home";
|
|
7
7
|
import { StandardPage } from "@/components/pages/Page";
|
|
8
8
|
import { PostPage } from "@/components/pages/Post";
|
|
9
9
|
|
|
10
|
-
export { collections, sections,
|
|
10
|
+
export { collections, sections, globals, elements };
|
|
11
11
|
|
|
12
12
|
// The registry and the plain settings, one file. Runtime-free: nothing
|
|
13
13
|
// here imports a route file or Next, so a script and the CLI's template
|
|
@@ -17,7 +17,7 @@ export const config = defineConfig({
|
|
|
17
17
|
sections,
|
|
18
18
|
elements,
|
|
19
19
|
collections,
|
|
20
|
-
|
|
20
|
+
globals,
|
|
21
21
|
pages: [HomePage, StandardPage, PostPage],
|
|
22
22
|
},
|
|
23
23
|
locales: { default: "en", supported: ["en"] },
|
|
@@ -25,11 +25,4 @@ export const config = defineConfig({
|
|
|
25
25
|
homePageSlug: "home",
|
|
26
26
|
// Editors can nest pages three deep.
|
|
27
27
|
pages: { tree: { depth: 3 } },
|
|
28
|
-
// Every section gets this select beside its own styles; the section
|
|
29
|
-
// wrapper emits it as data-tone, and app/globals.css styles it.
|
|
30
|
-
styles: {
|
|
31
|
-
sections: {
|
|
32
|
-
tone: f.select(["paper", "ink"]).default("paper"),
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
28
|
});
|
|
@@ -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];
|
|
@@ -14,10 +14,10 @@ import {
|
|
|
14
14
|
import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
|
|
15
15
|
import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
|
|
16
16
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
17
|
-
import { collections, config, elements, sections,
|
|
17
|
+
import { collections, config, elements, sections, globals } from "./config";
|
|
18
18
|
|
|
19
19
|
type Renderer = ReturnType<typeof createPageRenderer>;
|
|
20
|
-
type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof
|
|
20
|
+
type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof globals>>;
|
|
21
21
|
type Bound = {
|
|
22
22
|
db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
|
|
23
23
|
};
|
|
@@ -31,9 +31,11 @@ function bound(): Bound {
|
|
|
31
31
|
// ONE path index for both stores: pages and entries share the URL space.
|
|
32
32
|
const paths = new SupabasePathIndex(db);
|
|
33
33
|
const locales = config.locales;
|
|
34
|
-
const entries = new SupabaseEntryStore(db, collections, { paths, locales,
|
|
34
|
+
const entries = new SupabaseEntryStore(db, collections, { paths, locales, globals, elements });
|
|
35
35
|
const pages = new SupabasePageStore(db, {
|
|
36
36
|
sections, elements, entries, paths, locales,
|
|
37
|
+
// The custom section's columns toolset, so a column's images count as usage (spec 2026-09-14 §6).
|
|
38
|
+
custom: config.custom,
|
|
37
39
|
homePageSlug: config.homePageSlug,
|
|
38
40
|
tree: config.pages?.tree,
|
|
39
41
|
});
|
|
@@ -46,15 +48,16 @@ function bound(): Bound {
|
|
|
46
48
|
// The site layer: (locale, path) → page or entry, and the content API
|
|
47
49
|
// `q` wraps below. Not exported — the app reads through `q`, which adds
|
|
48
50
|
// draft awareness and the cache.
|
|
49
|
-
const site = createSmoodlySite({ stores, collections,
|
|
51
|
+
const site = createSmoodlySite({ stores, collections, globals, sections, elements, locales, homePageSlug: config.homePageSlug, styles: config.styles, custom: config.custom, pages: config.registry.pages ?? [] });
|
|
50
52
|
const renderer = createPageRenderer({
|
|
51
53
|
site,
|
|
52
54
|
sections,
|
|
55
|
+
elements,
|
|
53
56
|
pages: (config.registry.pages ?? []) as PageRegistration[],
|
|
54
57
|
href,
|
|
55
58
|
});
|
|
56
59
|
// 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,
|
|
60
|
+
const q = createSmoodlyQueries({ site, collections, globals });
|
|
58
61
|
cached = { db, pages, entries, assets, auth, site, renderer, q };
|
|
59
62
|
return cached;
|
|
60
63
|
}
|
|
@@ -83,7 +86,7 @@ export function publicAuth(): AuthConfig {
|
|
|
83
86
|
export const href = (path: string, _locale: string) => path;
|
|
84
87
|
|
|
85
88
|
/** The read API (spec 2026-09-11) for layouts and page views that fetch:
|
|
86
|
-
* `q().getEntries("articles", { locale, limit: 3 })`, `q().
|
|
89
|
+
* `q().getEntries("articles", { locale, limit: 3 })`, `q().getGlobal("header",
|
|
87
90
|
* { locale })`, `q().getPageTree({ locale })`. Typed by the registry, the
|
|
88
91
|
* live draft in the editor preview, cached under tags the admin expires.
|
|
89
92
|
* Rename it if `q` is not to your taste — this file is yours. */
|
|
@@ -37,6 +37,7 @@ create table if not exists "page_locales" (
|
|
|
37
37
|
"draft_version_id" uuid,
|
|
38
38
|
"published_version_id" uuid,
|
|
39
39
|
"updated_at" timestamptz not null default now(),
|
|
40
|
+
"updated_by" text,
|
|
40
41
|
primary key ("page_id", "locale")
|
|
41
42
|
);
|
|
42
43
|
|
|
@@ -49,19 +50,20 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
|
|
|
49
50
|
$$;
|
|
50
51
|
|
|
51
52
|
-- A version belongs to ONE locale of one page: each locale has its own
|
|
52
|
-
-- draft, its own publish and its own history.
|
|
53
|
+
-- draft, its own publish and its own history. One row per publish plus
|
|
54
|
+
-- the live draft (spec 2026-09-13 history §1): the unpublished draft row
|
|
55
|
+
-- is rewritten in place and saved_at is when its content last changed.
|
|
53
56
|
create table if not exists "page_versions" (
|
|
54
57
|
"id" uuid primary key default gen_random_uuid(),
|
|
55
58
|
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
56
59
|
"page_id" uuid not null references "pages" ("id") on delete cascade,
|
|
57
60
|
"locale" text not null,
|
|
58
61
|
"tree" jsonb not null,
|
|
59
|
-
"
|
|
60
|
-
"created_at" timestamptz not null default now()
|
|
62
|
+
"saved_at" timestamptz not null default now()
|
|
61
63
|
);
|
|
62
64
|
|
|
63
65
|
create index if not exists "page_versions_page_locale_idx"
|
|
64
|
-
on "page_versions" ("page_id", "locale", "
|
|
66
|
+
on "page_versions" ("page_id", "locale", "saved_at");
|
|
65
67
|
|
|
66
68
|
create table if not exists "entries" (
|
|
67
69
|
"id" uuid primary key default gen_random_uuid(),
|
|
@@ -98,6 +100,7 @@ create table if not exists "entry_locales" (
|
|
|
98
100
|
"draft_version_id" uuid,
|
|
99
101
|
"published_version_id" uuid,
|
|
100
102
|
"updated_at" timestamptz not null default now(),
|
|
103
|
+
"updated_by" text,
|
|
101
104
|
primary key ("entry_id", "locale")
|
|
102
105
|
);
|
|
103
106
|
|
|
@@ -107,19 +110,19 @@ create index if not exists "entry_locales_status_idx"
|
|
|
107
110
|
-- A version belongs to ONE locale of one entry and stores the MERGED
|
|
108
111
|
-- fields (shared + localized + slug) as they were at that save, so
|
|
109
112
|
-- restoring an old Finnish version restores the shared fields too.
|
|
110
|
-
-- Rows
|
|
113
|
+
-- Rows for every collection and every global item (spec 2026-09-14 §1).
|
|
114
|
+
-- One row per publish plus the live draft (spec 2026-09-13 history §1).
|
|
111
115
|
create table if not exists "entry_versions" (
|
|
112
116
|
"id" uuid primary key default gen_random_uuid(),
|
|
113
117
|
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
114
118
|
"entry_id" uuid not null references "entries" ("id") on delete cascade,
|
|
115
119
|
"locale" text not null,
|
|
116
120
|
"fields" jsonb not null,
|
|
117
|
-
"
|
|
118
|
-
"created_at" timestamptz not null default now()
|
|
121
|
+
"saved_at" timestamptz not null default now()
|
|
119
122
|
);
|
|
120
123
|
|
|
121
124
|
create index if not exists "entry_versions_entry_locale_idx"
|
|
122
|
-
on "entry_versions" ("entry_id", "locale", "
|
|
125
|
+
on "entry_versions" ("entry_id", "locale", "saved_at");
|
|
123
126
|
|
|
124
127
|
-- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
|
|
125
128
|
-- statement. Deliberately does not touch updated_at — a drag must not
|
|
@@ -506,12 +509,12 @@ select
|
|
|
506
509
|
e."sort",
|
|
507
510
|
e."created_at",
|
|
508
511
|
l."updated_at",
|
|
509
|
-
v."fields"->>'title' as "title",
|
|
510
|
-
(v."fields"->>'date')::timestamptz as "date",
|
|
511
|
-
v."fields"->'body' as "body"
|
|
512
|
+
coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
|
|
513
|
+
(coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
|
|
514
|
+
coalesce(v."fields", e."fields" || l."fields")->'body' as "body"
|
|
512
515
|
from "entries" e
|
|
513
516
|
join "entry_locales" l on l."entry_id" = e."id"
|
|
514
|
-
join "entry_versions" v on v."id" = l."published_version_id"
|
|
517
|
+
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
515
518
|
where e."collection" = 'posts' and l."status" = 'published';
|
|
516
519
|
|
|
517
520
|
create index if not exists "entries_posts_date_idx"
|
|
@@ -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
|
|
@@ -15,7 +15,7 @@ for an agent (or a person) building the site.
|
|
|
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
|
-
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `
|
|
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).
|
|
19
19
|
- `scripts/seed.ts` — the home page and the site item, written through the stores. `npm run seed` is idempotent.
|
|
20
20
|
- `app/globals.css` — a reset, a container, the two tones. Replace it.
|
|
21
21
|
- `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
|
|
@@ -49,12 +49,12 @@ Then create a migration with its view:
|
|
|
49
49
|
Save the output as `supabase/migrations/<timestamp>_people.sql` and apply
|
|
50
50
|
it with `supabase db push` (hosted) or `supabase db reset` (local).
|
|
51
51
|
|
|
52
|
-
## Add a
|
|
52
|
+
## Add a global item
|
|
53
53
|
|
|
54
|
-
Copy the `site` block in `smoodly/
|
|
55
|
-
object item (`fields`) is read in code with `q().
|
|
54
|
+
Copy the `site` block in `smoodly/globals.ts` and add it to `globals`. An
|
|
55
|
+
object item (`fields`) is read in code with `q().getGlobal("name")`; a
|
|
56
56
|
visual item (`section: SomeSection`) renders through its section and can
|
|
57
|
-
be placed on pages from the picker's
|
|
57
|
+
be placed on pages from the picker's Global group. Edit both under
|
|
58
58
|
Globals in the admin.
|
|
59
59
|
|
|
60
60
|
Reseed an empty database any time with `npm run seed`.
|
|
@@ -7,20 +7,20 @@ 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
|
-
// The `site`
|
|
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
12
|
// locale links are the visitor's only language switch, since locale
|
|
13
13
|
// detection is off (see i18n/routing.ts).
|
|
14
14
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
|
15
15
|
const { locale } = await params;
|
|
16
|
-
const site = await q().
|
|
16
|
+
const site = await q().getGlobal("site", { locale });
|
|
17
17
|
return { title: site?.name ?? "Smoodly site" };
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
|
|
21
21
|
const { locale } = await params;
|
|
22
22
|
if (!hasLocale(routing.locales, locale)) notFound();
|
|
23
|
-
const site = await q().
|
|
23
|
+
const site = await q().getGlobal("site", { locale });
|
|
24
24
|
const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
|
|
25
25
|
return (
|
|
26
26
|
<html lang={locale}>
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/* The blank starter's one stylesheet: a reset, a system font, a
|
|
2
|
-
container, prose defaults, the button element, and the two
|
|
3
|
-
|
|
2
|
+
container, prose defaults, the button element, and the two backgrounds
|
|
3
|
+
the Text section's setting selects. The editor's Spacing & layout
|
|
4
|
+
steps (Smoodly's styles set, smoodly/config.ts) render inline through
|
|
5
|
+
the section wrapper and read the --space-* / --width-* variables.
|
|
6
|
+
Replace freely. */
|
|
4
7
|
|
|
5
8
|
:root {
|
|
9
|
+
/* Smoodly's styles set reads these (smoodly/config.ts): editor steps become padding, width, alignment. */
|
|
10
|
+
--space-sm: 1.5rem; --space-md: 3rem; --space-lg: 4.5rem;
|
|
11
|
+
--width-narrow: 40rem; --width-wide: 90rem;
|
|
12
|
+
|
|
6
13
|
--paper: #ffffff;
|
|
7
14
|
--ink: #1a1a1a;
|
|
8
15
|
--muted: #666666;
|
|
9
16
|
--container: 720px;
|
|
17
|
+
--smd-gutter: 1rem; /* the custom section's rows share the container's side gutter */
|
|
10
18
|
}
|
|
19
|
+
@media (min-width: 768px) { :root { --space-sm: 2rem; --space-md: 4rem; --space-lg: 6rem; } }
|
|
11
20
|
|
|
12
21
|
* { box-sizing: border-box; }
|
|
13
22
|
html, body { margin: 0; padding: 0; }
|
|
@@ -23,15 +32,16 @@ body {
|
|
|
23
32
|
main { flex: 1; }
|
|
24
33
|
a { color: inherit; }
|
|
25
34
|
|
|
26
|
-
|
|
35
|
+
/* The container reads the editor's width step; without one, the design's width. */
|
|
36
|
+
.container { width: min(var(--smd-width, var(--container)), 100% - 2rem); margin-inline: auto; }
|
|
27
37
|
|
|
28
|
-
/*
|
|
29
|
-
|
|
30
|
-
[data-
|
|
31
|
-
|
|
38
|
+
/* The Text section: its own padding, and the background its setting selects. */
|
|
39
|
+
.text { padding-block: 3rem; }
|
|
40
|
+
[data-background="paper"] { background: var(--paper); color: var(--ink); }
|
|
41
|
+
[data-background="ink"] { background: var(--ink); color: var(--paper); }
|
|
32
42
|
|
|
33
|
-
.prose > * + * { margin-top: 1em; }
|
|
34
|
-
.prose h1, .prose h2, .prose h3 { line-height: 1.2; }
|
|
43
|
+
.prose > * + *, .smd-col > * + * { margin-top: 1em; } /* the custom section's columns read like prose */
|
|
44
|
+
.prose h1, .prose h2, .prose h3, .smd-col h1, .smd-col h2, .smd-col h3 { line-height: 1.2; }
|
|
35
45
|
|
|
36
46
|
/* The Button element, inside richtext. */
|
|
37
47
|
.button {
|
|
@@ -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>
|