create-smoodly-app 0.0.4 → 0.0.6
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/README.md +64 -10
- package/dist/index.js +66 -9
- package/package.json +1 -1
- package/templates/next/README.md +6 -5
- package/templates/next/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -1
- package/templates/next/app/(smoodly)/admin/layout.tsx +1 -1
- package/templates/next/app/[...path]/page.tsx +15 -0
- package/templates/next/app/page.tsx +5 -42
- package/templates/next/components/pages/Article.tsx +15 -0
- package/templates/next/components/pages/Home.tsx +35 -0
- package/templates/next/components/pages/Page.tsx +40 -0
- package/templates/next/components/sections/ArticleList.tsx +1 -1
- package/templates/next/package.json +2 -2
- package/templates/next/smoodly/admin.tsx +6 -0
- package/templates/next/smoodly/collections.ts +26 -0
- package/templates/next/smoodly/config.ts +27 -0
- package/templates/next/smoodly/ops.ts +11 -0
- package/templates/next/smoodly/sections.ts +8 -0
- package/templates/next/smoodly/server.ts +72 -0
- package/templates/next/supabase/migrations/00000000000000_smoodly.sql +92 -5
- package/templates/next/tsconfig.json +5 -0
- package/templates/next-intl/README.md +40 -0
- package/templates/next-intl/_gitignore +5 -0
- package/templates/next-intl/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -0
- package/templates/next-intl/app/(smoodly)/admin/layout.tsx +15 -0
- package/templates/next-intl/app/[locale]/[...path]/page.tsx +18 -0
- package/templates/next-intl/app/[locale]/layout.tsx +21 -0
- package/templates/next-intl/app/[locale]/page.tsx +15 -0
- package/templates/next-intl/components/pages/Article.tsx +15 -0
- package/templates/next-intl/components/pages/Home.tsx +35 -0
- package/templates/next-intl/components/pages/Page.tsx +40 -0
- package/templates/next-intl/components/sections/ArticleList.tsx +31 -0
- package/templates/next-intl/components/sections/CtaBanner.tsx +35 -0
- package/templates/next-intl/components/sections/Hero.tsx +24 -0
- package/templates/next-intl/i18n/navigation.ts +6 -0
- package/templates/next-intl/i18n/request.ts +17 -0
- package/templates/next-intl/i18n/routing.ts +17 -0
- package/templates/next-intl/next.config.ts +8 -0
- package/templates/next-intl/package.json +25 -0
- package/templates/next-intl/proxy.ts +11 -0
- package/templates/{next/lib → next-intl}/smoodly/admin.tsx +2 -2
- package/templates/{next → next-intl/smoodly}/collections.ts +1 -0
- package/templates/next-intl/smoodly/config.ts +31 -0
- package/templates/{next/lib → next-intl}/smoodly/ops.ts +3 -3
- package/templates/next-intl/smoodly/sections.ts +8 -0
- package/templates/next-intl/smoodly/server.ts +73 -0
- package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +427 -0
- package/templates/next-intl/tsconfig.json +41 -0
- package/templates/next/app/[slug]/page.tsx +0 -42
- package/templates/next/lib/smoodly/index.ts +0 -64
- package/templates/next/sections.ts +0 -8
- package/templates/next/smoodly.config.ts +0 -19
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Server-only glue: the client, both stores, the site layer and the
|
|
2
|
+
// renderer, constructed lazily so `next build` doesn't need env vars at
|
|
3
|
+
// import time. The env shape is the package's (SMOODLY_*); whether the
|
|
4
|
+
// secret is a service-role key or a cloud write key is invisible here.
|
|
5
|
+
import {
|
|
6
|
+
createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex,
|
|
7
|
+
} from "smoodly";
|
|
8
|
+
import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
|
|
9
|
+
import { createPageRenderer, type PageRegistration } from "smoodly/next";
|
|
10
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
11
|
+
import { collections, config, sections } from "./config";
|
|
12
|
+
|
|
13
|
+
let cached: { db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth } | null = null;
|
|
14
|
+
|
|
15
|
+
function bound() {
|
|
16
|
+
if (cached) return cached;
|
|
17
|
+
const env = smoodlyEnv();
|
|
18
|
+
const db = createSmoodlyClient(env);
|
|
19
|
+
// ONE path index for both stores: pages and entries share the URL space.
|
|
20
|
+
const paths = new SupabasePathIndex(db);
|
|
21
|
+
const locales = config.locales;
|
|
22
|
+
const entries = new SupabaseEntryStore(db, collections, { paths, locales });
|
|
23
|
+
const pages = new SupabasePageStore(db, {
|
|
24
|
+
sections, entries, paths, locales,
|
|
25
|
+
homePageSlug: config.homePageSlug,
|
|
26
|
+
tree: config.pages?.tree,
|
|
27
|
+
});
|
|
28
|
+
const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
|
|
29
|
+
cached = { db, pages, entries, auth };
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function stores() {
|
|
34
|
+
const { pages, entries } = bound();
|
|
35
|
+
return { pages, entries };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The server-side client (seed script, one-off admin tasks). */
|
|
39
|
+
export const client = () => bound().db;
|
|
40
|
+
|
|
41
|
+
/** The admin gate: session verified against the auth project, editors row read with the write key. */
|
|
42
|
+
export const adminAuth = () => bound().auth;
|
|
43
|
+
|
|
44
|
+
/** What the browser needs to sign in — public values only. */
|
|
45
|
+
export function publicAuth(): AuthConfig {
|
|
46
|
+
const env = smoodlyEnv();
|
|
47
|
+
return { url: env.authUrl, key: env.authKey };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Locale-relative CMS path → site URL: the one seam for the renderer
|
|
51
|
+
* (metadata alternates, the `page` prop's links) and the admin (canvas,
|
|
52
|
+
* View live). The identity for a single-language site; with next-intl it
|
|
53
|
+
* becomes a locale-prefixing lookup. */
|
|
54
|
+
export const href = (path: string, _locale: string) => path;
|
|
55
|
+
|
|
56
|
+
/** The site layer: (locale, path) → page or entry, and the content API
|
|
57
|
+
* (site.getPagesTree for menus, site.getEntries for lists). */
|
|
58
|
+
export const site = createSmoodlySite({
|
|
59
|
+
stores,
|
|
60
|
+
collections,
|
|
61
|
+
sections,
|
|
62
|
+
locales: config.locales,
|
|
63
|
+
homePageSlug: config.homePageSlug,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** The two route files call these. */
|
|
67
|
+
export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata } = createPageRenderer({
|
|
68
|
+
site,
|
|
69
|
+
sections,
|
|
70
|
+
pages: (config.registry.pages ?? []) as PageRegistration[],
|
|
71
|
+
href,
|
|
72
|
+
});
|
|
@@ -11,18 +11,37 @@ $$;
|
|
|
11
11
|
create table if not exists "pages" (
|
|
12
12
|
"id" uuid primary key default gen_random_uuid(),
|
|
13
13
|
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
14
|
+
"parent_id" uuid references "pages" ("id") on delete restrict,
|
|
14
15
|
"slug" text not null,
|
|
15
|
-
"
|
|
16
|
+
"title" text not null,
|
|
17
|
+
"i18n" jsonb,
|
|
18
|
+
"template" text,
|
|
16
19
|
"status" text not null default 'draft',
|
|
17
20
|
"sort" numeric,
|
|
18
21
|
"locales" text[],
|
|
19
22
|
"draft_version_id" uuid,
|
|
20
23
|
"published_version_id" uuid,
|
|
21
24
|
"created_at" timestamptz not null default now(),
|
|
22
|
-
"updated_at" timestamptz not null default now()
|
|
23
|
-
unique ("space_id", "slug")
|
|
25
|
+
"updated_at" timestamptz not null default now()
|
|
24
26
|
);
|
|
25
27
|
|
|
28
|
+
-- The tree is the URL (spec 2026-09-05 §1): a node stores its own segment
|
|
29
|
+
-- and its parent. Siblings are unique per segment; NULLS NOT DISTINCT so
|
|
30
|
+
-- two root nodes cannot share one either. template null = folder.
|
|
31
|
+
create unique index if not exists "pages_sibling_slug_key"
|
|
32
|
+
on "pages" ("space_id", "parent_id", "slug") nulls not distinct;
|
|
33
|
+
|
|
34
|
+
create index if not exists "pages_parent_idx"
|
|
35
|
+
on "pages" ("space_id", "parent_id", "sort");
|
|
36
|
+
|
|
37
|
+
-- Sibling reorder for pages: same shape as smoodly_reorder for entries.
|
|
38
|
+
create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
|
|
39
|
+
update "pages" pg set "sort" = u.ord
|
|
40
|
+
from unnest(p_ids) with ordinality as u(id, ord)
|
|
41
|
+
where pg."id" = u.id
|
|
42
|
+
and pg."space_id" = "smoodly_current_space"();
|
|
43
|
+
$$;
|
|
44
|
+
|
|
26
45
|
create table if not exists "page_versions" (
|
|
27
46
|
"id" uuid primary key default gen_random_uuid(),
|
|
28
47
|
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
@@ -52,6 +71,7 @@ create table if not exists "entries" (
|
|
|
52
71
|
"id" uuid primary key default gen_random_uuid(),
|
|
53
72
|
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
54
73
|
"collection" text not null,
|
|
74
|
+
"parent_id" uuid references "entries" ("id") on delete restrict,
|
|
55
75
|
"fields" jsonb not null default '{}'::jsonb,
|
|
56
76
|
"i18n" jsonb,
|
|
57
77
|
"status" text not null default 'draft',
|
|
@@ -61,8 +81,22 @@ create table if not exists "entries" (
|
|
|
61
81
|
"updated_at" timestamptz not null default now()
|
|
62
82
|
);
|
|
63
83
|
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
-- Entry slugs are unique among SIBLINGS, like pages: one rule for every
|
|
85
|
+
-- node in the tree, and the URL is the tree. NULLS NOT DISTINCT so two
|
|
86
|
+
-- ROOT entries (parent_id null) cannot share a slug either. The partial
|
|
87
|
+
-- predicate keeps absent, null, empty or non-string slugs out of the
|
|
88
|
+
-- index entirely: with NULLS NOT DISTINCT a null slug would otherwise
|
|
89
|
+
-- collide with the next null slug, and a collection with no slug field
|
|
90
|
+
-- at all could hold only one entry per parent. jsonb_typeof(...) =
|
|
91
|
+
-- 'string' is required because ->> renders ANY JSON scalar as text —
|
|
92
|
+
-- a number, boolean or null slug would otherwise print as non-empty
|
|
93
|
+
-- text and claim the index; only a non-empty string slug claims a URL,
|
|
94
|
+
-- absent, null, empty or non-string slugs never conflict — the same
|
|
95
|
+
-- rule as slugOf in paths.ts. A flat collection (every entry a root)
|
|
96
|
+
-- therefore behaves exactly as per-collection uniqueness did.
|
|
97
|
+
create unique index if not exists "entries_sibling_slug_key"
|
|
98
|
+
on "entries" ("space_id", "collection", "parent_id", ("fields"->>'slug')) nulls not distinct
|
|
99
|
+
where (jsonb_typeof("fields"->'slug') = 'string' and "fields"->>'slug' <> '');
|
|
66
100
|
|
|
67
101
|
create index if not exists "entries_collection_status_idx"
|
|
68
102
|
on "entries" ("space_id", "collection", "status");
|
|
@@ -70,6 +104,9 @@ create index if not exists "entries_collection_status_idx"
|
|
|
70
104
|
create index if not exists "entries_collection_sort_idx"
|
|
71
105
|
on "entries" ("space_id", "collection", "sort");
|
|
72
106
|
|
|
107
|
+
create index if not exists "entries_parent_idx"
|
|
108
|
+
on "entries" ("space_id", "collection", "parent_id");
|
|
109
|
+
|
|
73
110
|
-- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
|
|
74
111
|
-- statement. Deliberately does not touch updated_at — a drag must not
|
|
75
112
|
-- make the whole collection look freshly edited. Scoped to the current
|
|
@@ -95,6 +132,38 @@ create table if not exists "refs" (
|
|
|
95
132
|
primary key ("source_kind", "source_id", "source_field", "target_id")
|
|
96
133
|
);
|
|
97
134
|
|
|
135
|
+
-- The URL index (spec 2026-09-05 §4): the refs counterpart for paths.
|
|
136
|
+
-- Derived — the stores rewrite a target's rows on every create, rename,
|
|
137
|
+
-- move and delete — so one primary-key hit resolves any URL in any locale
|
|
138
|
+
-- and no page or entry can share a URL with another in any locale.
|
|
139
|
+
-- Rows exist for drafts too (the editor needs them); the read policy
|
|
140
|
+
-- admits a row only when its target is published.
|
|
141
|
+
create table if not exists "paths" (
|
|
142
|
+
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
143
|
+
"locale" text not null,
|
|
144
|
+
"path" text not null,
|
|
145
|
+
"kind" text not null check ("kind" in ('page', 'entry')),
|
|
146
|
+
"target_id" uuid not null,
|
|
147
|
+
primary key ("space_id", "locale", "path")
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
create index if not exists "paths_target_idx"
|
|
151
|
+
on "paths" ("space_id", "kind", "target_id");
|
|
152
|
+
|
|
153
|
+
-- Replace the targets' rows with the given rows in ONE call: a SQL
|
|
154
|
+
-- function runs in the caller's transaction, so a primary-key collision
|
|
155
|
+
-- rolls the whole rewrite back. Inserted rows get their space from the
|
|
156
|
+
-- a_set_space trigger (the claim, or the default space).
|
|
157
|
+
create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql as $$
|
|
158
|
+
delete from "paths" p
|
|
159
|
+
using jsonb_to_recordset(p_targets) as t(kind text, id uuid)
|
|
160
|
+
where p."kind" = t.kind and p."target_id" = t.id
|
|
161
|
+
and p."space_id" = "smoodly_current_space"();
|
|
162
|
+
insert into "paths" ("locale", "path", "kind", "target_id")
|
|
163
|
+
select r.locale, r.path, r.kind, r.id
|
|
164
|
+
from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid);
|
|
165
|
+
$$;
|
|
166
|
+
|
|
98
167
|
-- RLS on every core table. The service role bypasses it (self-host
|
|
99
168
|
-- adapters untouched). The policy set that follows (space layer) admits
|
|
100
169
|
-- a key to its own space: everything for a write key, published rows for
|
|
@@ -108,6 +177,7 @@ alter table "saved_sections" enable row level security;
|
|
|
108
177
|
alter table "global_sections" enable row level security;
|
|
109
178
|
alter table "entries" enable row level security;
|
|
110
179
|
alter table "refs" enable row level security;
|
|
180
|
+
alter table "paths" enable row level security;
|
|
111
181
|
|
|
112
182
|
-- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
|
|
113
183
|
-- for everyone with ONE definition. Self-host fills them by hand or
|
|
@@ -234,6 +304,10 @@ drop trigger if exists "smoodly_a_set_space" on "refs";
|
|
|
234
304
|
create trigger "smoodly_a_set_space" before insert on "refs"
|
|
235
305
|
for each row execute function "smoodly_set_space"();
|
|
236
306
|
|
|
307
|
+
drop trigger if exists "smoodly_a_set_space" on "paths";
|
|
308
|
+
create trigger "smoodly_a_set_space" before insert on "paths"
|
|
309
|
+
for each row execute function "smoodly_set_space"();
|
|
310
|
+
|
|
237
311
|
drop trigger if exists "smoodly_a_set_space" on "editors";
|
|
238
312
|
create trigger "smoodly_a_set_space" before insert on "editors"
|
|
239
313
|
for each row execute function "smoodly_set_space"();
|
|
@@ -262,6 +336,10 @@ drop trigger if exists "smoodly_b_row_cap" on "refs";
|
|
|
262
336
|
create trigger "smoodly_b_row_cap" before insert on "refs"
|
|
263
337
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
264
338
|
|
|
339
|
+
drop trigger if exists "smoodly_b_row_cap" on "paths";
|
|
340
|
+
create trigger "smoodly_b_row_cap" before insert on "paths"
|
|
341
|
+
for each row execute function "smoodly_enforce_row_cap"();
|
|
342
|
+
|
|
265
343
|
drop policy if exists "smoodly_write" on "pages";
|
|
266
344
|
create policy "smoodly_write" on "pages" for all
|
|
267
345
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
@@ -308,6 +386,15 @@ create policy "smoodly_write" on "refs" for all
|
|
|
308
386
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
309
387
|
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
310
388
|
|
|
389
|
+
drop policy if exists "smoodly_write" on "paths";
|
|
390
|
+
create policy "smoodly_write" on "paths" for all
|
|
391
|
+
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
392
|
+
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
393
|
+
|
|
394
|
+
drop policy if exists "smoodly_read" on "paths";
|
|
395
|
+
create policy "smoodly_read" on "paths" for select
|
|
396
|
+
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (("kind" = 'page' and exists (select 1 from "pages" p where p."id" = "paths"."target_id" and p."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entries" e where e."id" = "paths"."target_id" and e."status" = 'published'))));
|
|
397
|
+
|
|
311
398
|
drop policy if exists "smoodly_write" on "editors";
|
|
312
399
|
create policy "smoodly_write" on "editors" for all
|
|
313
400
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Smoodly site
|
|
2
|
+
|
|
3
|
+
Created by `create-smoodly-app`. Content lives in Supabase; the admin is
|
|
4
|
+
mounted at `/admin` from the `smoodly` package.
|
|
5
|
+
|
|
6
|
+
npm run dev # http://localhost:3000 — /admin is the editor
|
|
7
|
+
|
|
8
|
+
## Where things are
|
|
9
|
+
|
|
10
|
+
- `app/[locale]/page.tsx` — the home page: one line calling renderSmoodlyPath([], { locale }).
|
|
11
|
+
- `app/[locale]/[...path]/page.tsx` — every other page and every collection entry, at any depth, with translated segments per locale.
|
|
12
|
+
- `app/[locale]/layout.tsx` — the site's root layout (`<html lang>`, next-intl's provider); the admin has its own under `app/(smoodly)/admin/`.
|
|
13
|
+
- `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).
|
|
14
|
+
- `components/pages/` — the page shapes and entry pages: schema + view, one file each.
|
|
15
|
+
- `components/sections/` — the building blocks editors compose with. Add one, list it in `smoodly/sections.ts`.
|
|
16
|
+
- `smoodly/collections.ts` — structured content (people, articles). Add a collection, then add its view to a new migration with `collectionSQL` from `smoodly`.
|
|
17
|
+
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts`, `sections.ts`, `server.ts` (stores, site layer, renderer), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount).
|
|
18
|
+
- `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
|
|
19
|
+
|
|
20
|
+
Translated URLs: a page's slug and title are edited per locale in the
|
|
21
|
+
admin's page settings; a collection's base segment per locale is its
|
|
22
|
+
`path` in `smoodly/collections.ts`. UI strings of your own go through
|
|
23
|
+
next-intl's messages — add `messages` in `i18n/request.ts`:
|
|
24
|
+
https://next-intl.dev/docs/usage/configuration
|
|
25
|
+
|
|
26
|
+
## Editors
|
|
27
|
+
|
|
28
|
+
Anyone who may open `/admin` is a Supabase Auth user with a row in the
|
|
29
|
+
`editors` table. To add one: create the user in Supabase Studio
|
|
30
|
+
(Authentication → Users), then in the SQL editor:
|
|
31
|
+
|
|
32
|
+
insert into editors (user_id, role) values ('<the user id>', 'editor');
|
|
33
|
+
|
|
34
|
+
## Hosted Supabase
|
|
35
|
+
|
|
36
|
+
supabase init # if the CLI has not been set up here yet
|
|
37
|
+
supabase link # pick the project
|
|
38
|
+
supabase db push # applies supabase/migrations/
|
|
39
|
+
|
|
40
|
+
Then put the project's URL and keys in `.env.local`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AdminPage as default } from "@/smoodly/admin";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { AdminLayout } from "@/smoodly/admin";
|
|
3
|
+
|
|
4
|
+
// The admin's own root layout: the site's is app/[locale]/layout.tsx and
|
|
5
|
+
// the admin sits outside the locale segment (the proxy skips /admin), so
|
|
6
|
+
// Next needs a second <html> here.
|
|
7
|
+
export default function AdminRootLayout({ children }: { children: ReactNode }) {
|
|
8
|
+
return (
|
|
9
|
+
<html lang="en">
|
|
10
|
+
<body>
|
|
11
|
+
<AdminLayout>{children}</AdminLayout>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
|
|
2
|
+
|
|
3
|
+
type Params = Promise<{ locale: string; path: string[] }>;
|
|
4
|
+
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
|
5
|
+
|
|
6
|
+
// Smoodly: everything else — pages at any depth and collection entries
|
|
7
|
+
// under their `path`, with translated segments per locale. Add
|
|
8
|
+
// app/[locale]/<segment>/page.tsx beside this file and it wins for that
|
|
9
|
+
// exact path; deeper paths still resolve here.
|
|
10
|
+
export async function generateMetadata({ params }: { params: Params }) {
|
|
11
|
+
const { locale, path } = await params;
|
|
12
|
+
return smoodlyMetadata(path, { locale });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default async function Page({ params, searchParams }: { params: Params; searchParams: SearchParams }) {
|
|
16
|
+
const { locale, path } = await params;
|
|
17
|
+
return renderSmoodlyPath(path, { locale, searchParams });
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { hasLocale, NextIntlClientProvider } from "next-intl";
|
|
4
|
+
import { routing } from "@/i18n/routing";
|
|
5
|
+
|
|
6
|
+
export const metadata = { title: "Smoodly site" };
|
|
7
|
+
|
|
8
|
+
// The site's root layout. The admin has its own under app/(smoodly)/admin —
|
|
9
|
+
// two root layouts, a full page load between them, which is fine: they
|
|
10
|
+
// are separate apps in spirit.
|
|
11
|
+
export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
|
|
12
|
+
const { locale } = await params;
|
|
13
|
+
if (!hasLocale(routing.locales, locale)) notFound();
|
|
14
|
+
return (
|
|
15
|
+
<html lang={locale}>
|
|
16
|
+
<body>
|
|
17
|
+
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
|
|
2
|
+
|
|
3
|
+
type Params = Promise<{ locale: string }>;
|
|
4
|
+
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
|
5
|
+
|
|
6
|
+
// Smoodly: the home page, in the locale next-intl's proxy resolved. Editing
|
|
7
|
+
// this file replaces or wraps the CMS home; the shapes it can render live
|
|
8
|
+
// in components/pages/.
|
|
9
|
+
export async function generateMetadata({ params }: { params: Params }) {
|
|
10
|
+
return smoodlyMetadata([], { locale: (await params).locale });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default async function Page({ params, searchParams }: { params: Params; searchParams: SearchParams }) {
|
|
14
|
+
return renderSmoodlyPath([], { locale: (await params).locale, searchParams });
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { smoodly } from "smoodly";
|
|
2
|
+
import { articles } from "@/smoodly/collections";
|
|
3
|
+
|
|
4
|
+
// The entry page for `articles`, served at the collection's `path`
|
|
5
|
+
// (/posts/<slug>). Fields only; refs arrive resolved (author is a person).
|
|
6
|
+
export const ArticlePage = smoodly.entryPage(articles, ({ entry, path }) => {
|
|
7
|
+
const a = entry.fields as { title: string; excerpt?: string; author?: { name: string; role?: string } | null };
|
|
8
|
+
return (
|
|
9
|
+
<article data-path={path}>
|
|
10
|
+
<h1>{a.title}</h1>
|
|
11
|
+
{a.author && <p>By {a.author.name}{a.author.role ? `, ${a.author.role}` : ""}</p>}
|
|
12
|
+
{a.excerpt && <p>{a.excerpt}</p>}
|
|
13
|
+
</article>
|
|
14
|
+
);
|
|
15
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
2
|
+
import { Hero } from "../sections/Hero";
|
|
3
|
+
import { ArticleList } from "../sections/ArticleList";
|
|
4
|
+
import { CtaBanner } from "../sections/CtaBanner";
|
|
5
|
+
|
|
6
|
+
// A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
|
|
7
|
+
// `slug` makes it fixed — one record the editor can neither delete nor
|
|
8
|
+
// duplicate — and the config's homePageSlug maps that record to "/".
|
|
9
|
+
export const homeSchema = smoodly.schema.page({
|
|
10
|
+
name: "home",
|
|
11
|
+
title: "Home",
|
|
12
|
+
slug: "home",
|
|
13
|
+
zones: {
|
|
14
|
+
hero: z.sections([Hero]).max(1),
|
|
15
|
+
body: z.freeform({ sections: [ArticleList] }),
|
|
16
|
+
// Locked: fields only. One CtaBanner, always there, never movable.
|
|
17
|
+
prefooter: z.locked(CtaBanner),
|
|
18
|
+
},
|
|
19
|
+
meta: { seo: true },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function HomeView({ zones }: Props<typeof homeSchema>) {
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<Zone of={zones.hero} />
|
|
26
|
+
<Zone of={zones.body} />
|
|
27
|
+
<Zone of={zones.prefooter} />
|
|
28
|
+
<footer>
|
|
29
|
+
<p>Hard-coded footer — owned by the developer, invisible to the editor.</p>
|
|
30
|
+
</footer>
|
|
31
|
+
</>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const HomePage = smoodly.page(homeSchema, HomeView);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
3
|
+
import { Hero } from "../sections/Hero";
|
|
4
|
+
import { ArticleList } from "../sections/ArticleList";
|
|
5
|
+
import { CtaBanner } from "../sections/CtaBanner";
|
|
6
|
+
|
|
7
|
+
// Open registration: editors create as many of these as they like, at
|
|
8
|
+
// any depth the config allows. `page` is where this one sits in the tree.
|
|
9
|
+
export const pageSchema = smoodly.schema.page({
|
|
10
|
+
name: "page",
|
|
11
|
+
title: "Page",
|
|
12
|
+
zones: {
|
|
13
|
+
hero: z.sections([Hero]).max(1),
|
|
14
|
+
body: z.freeform({ sections: [ArticleList] }),
|
|
15
|
+
prefooter: z.locked(CtaBanner),
|
|
16
|
+
},
|
|
17
|
+
meta: { seo: true },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{page.ancestors.length > 0 && (
|
|
24
|
+
// `url` is the ancestor's site URL through the app's href seam
|
|
25
|
+
// (/fi/yritys under next-intl); `path` would be the bare CMS path.
|
|
26
|
+
<nav aria-label="Breadcrumb">
|
|
27
|
+
{page.ancestors.map((a) => (
|
|
28
|
+
<span key={a.path}>{a.hasPage ? <Link href={a.url}>{a.title}</Link> : a.title} / </span>
|
|
29
|
+
))}
|
|
30
|
+
{page.title}
|
|
31
|
+
</nav>
|
|
32
|
+
)}
|
|
33
|
+
<Zone of={zones.hero} />
|
|
34
|
+
<Zone of={zones.body} />
|
|
35
|
+
<Zone of={zones.prefooter} />
|
|
36
|
+
</>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const StandardPage = smoodly.page(pageSchema, PageView);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
import { articles } from "@/smoodly/collections";
|
|
3
|
+
|
|
4
|
+
export const articleListSchema = smoodly.schema.section({
|
|
5
|
+
name: "articleList",
|
|
6
|
+
title: "Article list",
|
|
7
|
+
fields: {
|
|
8
|
+
title: f.text(),
|
|
9
|
+
// .resolve("author") — hydrate each article AND follow its author ref
|
|
10
|
+
// one level (article → person), all in resolveTree's batched pass.
|
|
11
|
+
items: f.refList(() => articles).resolve("author"),
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function ArticleListView({ title, items, styles }: Props<typeof articleListSchema>) {
|
|
16
|
+
return (
|
|
17
|
+
<smoodly.Section styles={styles}>
|
|
18
|
+
<h2>{title}</h2>
|
|
19
|
+
<ul>
|
|
20
|
+
{/* an item demoted/deleted after publish resolves to null — skip it */}
|
|
21
|
+
{items.filter(Boolean).map((article, i) => (
|
|
22
|
+
<li key={i}>
|
|
23
|
+
<strong>{article.title}</strong> — {article.author?.name ?? "unknown author"}
|
|
24
|
+
</li>
|
|
25
|
+
))}
|
|
26
|
+
</ul>
|
|
27
|
+
</smoodly.Section>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const ArticleList = smoodly.section(articleListSchema, ArticleListView);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// The locked-zone section: every page renders exactly one of these in its
|
|
4
|
+
// `prefooter` zone. Editors change the words, never the structure — no
|
|
5
|
+
// add, remove, move or duplicate. The editor materializes the node from
|
|
6
|
+
// `sample` the first time it opens a page whose prefooter is empty.
|
|
7
|
+
export const ctaBannerSchema = smoodly.schema.section({
|
|
8
|
+
name: "ctaBanner",
|
|
9
|
+
title: "CTA banner",
|
|
10
|
+
description: "The closing call to action under every page.",
|
|
11
|
+
fields: {
|
|
12
|
+
heading: f.text(),
|
|
13
|
+
buttonLabel: f.text(),
|
|
14
|
+
buttonHref: f.text(),
|
|
15
|
+
},
|
|
16
|
+
styles: {
|
|
17
|
+
tone: f.select(["light", "dark"]).default("dark"),
|
|
18
|
+
},
|
|
19
|
+
sample: {
|
|
20
|
+
heading: "Ready to try Smoodly?",
|
|
21
|
+
buttonLabel: "Read the docs",
|
|
22
|
+
buttonHref: "/docs/intro",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function CtaBannerView({ heading, buttonLabel, buttonHref, styles }: Props<typeof ctaBannerSchema>) {
|
|
27
|
+
return (
|
|
28
|
+
<smoodly.Section styles={styles}>
|
|
29
|
+
<h2>{heading}</h2>
|
|
30
|
+
<a href={buttonHref}>{buttonLabel}</a>
|
|
31
|
+
</smoodly.Section>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const CtaBanner = smoodly.section(ctaBannerSchema, CtaBannerView);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
export const heroSchema = smoodly.schema.section({
|
|
4
|
+
name: "hero",
|
|
5
|
+
title: "Hero",
|
|
6
|
+
fields: {
|
|
7
|
+
heading: f.text(),
|
|
8
|
+
kicker: f.text().optional(),
|
|
9
|
+
},
|
|
10
|
+
styles: {
|
|
11
|
+
tone: f.select(["light", "dark"]).default("light"),
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function HeroView({ heading, kicker, styles }: Props<typeof heroSchema>) {
|
|
16
|
+
return (
|
|
17
|
+
<smoodly.Section styles={styles}>
|
|
18
|
+
{kicker ? <p>{kicker}</p> : null}
|
|
19
|
+
<h1>{heading}</h1>
|
|
20
|
+
</smoodly.Section>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const Hero = smoodly.section(heroSchema, HeroView);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createNavigation } from "next-intl/navigation";
|
|
2
|
+
import { routing } from "./routing";
|
|
3
|
+
|
|
4
|
+
// Locale-aware Link/redirect/router, and getPathname — the function
|
|
5
|
+
// smoodly/server.ts binds as `href`.
|
|
6
|
+
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { locale as rootLocale } from "next/root-params";
|
|
2
|
+
import { hasLocale } from "next-intl";
|
|
3
|
+
import { getRequestConfig } from "next-intl/server";
|
|
4
|
+
import { routing } from "./routing";
|
|
5
|
+
|
|
6
|
+
// Runs once per request that touches next-intl. An unknown or absent
|
|
7
|
+
// locale (the admin lives outside app/[locale]) falls back to the default
|
|
8
|
+
// rather than 404ing. The site has no UI strings of its own yet, but
|
|
9
|
+
// NextIntlClientProvider wants a messages object all the same — fill it
|
|
10
|
+
// in when it does: https://next-intl.dev/docs/usage/configuration
|
|
11
|
+
export default getRequestConfig(async ({ locale }) => {
|
|
12
|
+
const requested = locale ?? (await rootLocale());
|
|
13
|
+
return {
|
|
14
|
+
locale: hasLocale(routing.locales, requested) ? requested : routing.defaultLocale,
|
|
15
|
+
messages: {},
|
|
16
|
+
};
|
|
17
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineRouting } from "next-intl/routing";
|
|
2
|
+
|
|
3
|
+
// The one place the locale set is declared. smoodly/config.ts imports it
|
|
4
|
+
// (the proxy runs in the edge runtime and cannot import the config, so it
|
|
5
|
+
// could not go the other way). `alternateLinks: false`: next-intl would
|
|
6
|
+
// derive hreflang from URL patterns, which is wrong once slugs translate —
|
|
7
|
+
// smoodlyMetadata emits alternates from the CMS paths instead.
|
|
8
|
+
// `localeDetection: false` for the same reason — a redirect that keeps the
|
|
9
|
+
// English segments under `/fi/` lands on a 404; the visitor picks a
|
|
10
|
+
// language through the site's own switcher.
|
|
11
|
+
export const routing = defineRouting({
|
|
12
|
+
locales: ["en", "fi"],
|
|
13
|
+
defaultLocale: "en",
|
|
14
|
+
localePrefix: "as-needed",
|
|
15
|
+
alternateLinks: false,
|
|
16
|
+
localeDetection: false,
|
|
17
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smoodly-site",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@supabase/supabase-js": "^2.113.0",
|
|
13
|
+
"next": "^16.3.4",
|
|
14
|
+
"react": "^19",
|
|
15
|
+
"react-dom": "^19",
|
|
16
|
+
"smoodly": "0.0.6",
|
|
17
|
+
"next-intl": "^4.14.2"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^26.4.1",
|
|
21
|
+
"@types/react": "^19",
|
|
22
|
+
"@types/react-dom": "^19.2.5",
|
|
23
|
+
"typescript": "^5"
|
|
24
|
+
}
|
|
25
|
+
}
|