create-smoodly-app 0.0.6 → 0.0.8
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 +2 -2
- package/templates/next/app/layout.tsx +18 -2
- package/templates/next/components/pages/Home.tsx +2 -1
- package/templates/next/components/pages/Page.tsx +2 -1
- package/templates/next/components/sections/Footer.tsx +33 -0
- package/templates/next/components/sections/Testimonials.tsx +30 -0
- package/templates/next/package.json +1 -1
- package/templates/next/smoodly/collections.ts +10 -3
- package/templates/next/smoodly/config.ts +3 -1
- package/templates/next/smoodly/sections.ts +3 -1
- package/templates/next/smoodly/server.ts +4 -3
- package/templates/next/smoodly/shared.ts +22 -0
- package/templates/next/supabase/migrations/00000000000000_smoodly.sql +143 -101
- package/templates/next-intl/app/[locale]/layout.tsx +12 -1
- package/templates/next-intl/components/pages/Home.tsx +2 -1
- package/templates/next-intl/components/pages/Page.tsx +2 -1
- package/templates/next-intl/components/sections/Footer.tsx +33 -0
- package/templates/next-intl/components/sections/Testimonials.tsx +30 -0
- package/templates/next-intl/package.json +1 -1
- package/templates/next-intl/smoodly/collections.ts +10 -3
- package/templates/next-intl/smoodly/config.ts +3 -1
- package/templates/next-intl/smoodly/sections.ts +3 -1
- package/templates/next-intl/smoodly/server.ts +4 -3
- package/templates/next-intl/smoodly/shared.ts +22 -0
- package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +143 -101
|
@@ -12,28 +12,34 @@ 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
14
|
"parent_id" uuid references "pages" ("id") on delete restrict,
|
|
15
|
-
"slug" text not null,
|
|
16
|
-
"title" text not null,
|
|
17
|
-
"i18n" jsonb,
|
|
18
15
|
"template" text,
|
|
19
|
-
"status" text not null default 'draft',
|
|
20
16
|
"sort" numeric,
|
|
21
|
-
"locales" text[],
|
|
22
|
-
"draft_version_id" uuid,
|
|
23
|
-
"published_version_id" uuid,
|
|
24
17
|
"created_at" timestamptz not null default now(),
|
|
25
18
|
"updated_at" timestamptz not null default now()
|
|
26
19
|
);
|
|
27
20
|
|
|
28
|
-
-- The tree is the URL (spec 2026-09-05 §1): a node stores its
|
|
29
|
-
--
|
|
30
|
-
--
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
21
|
+
-- The tree is the URL (spec 2026-09-05 §1): a node stores its parent and
|
|
22
|
+
-- its position; template null = folder. Everything a locale owns — the
|
|
23
|
+
-- node's own segment, title, publish state and version pointers — lives
|
|
24
|
+
-- in page_locales, one row per locale the page exists in (spec 2026-09-06
|
|
25
|
+
-- §2). There is no sibling-slug index: URL uniqueness per locale is the
|
|
26
|
+
-- primary key of "paths", which the stores write inside the same operation.
|
|
34
27
|
create index if not exists "pages_parent_idx"
|
|
35
28
|
on "pages" ("space_id", "parent_id", "sort");
|
|
36
29
|
|
|
30
|
+
create table if not exists "page_locales" (
|
|
31
|
+
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
32
|
+
"page_id" uuid not null references "pages" ("id") on delete cascade,
|
|
33
|
+
"locale" text not null,
|
|
34
|
+
"slug" text not null,
|
|
35
|
+
"title" text not null,
|
|
36
|
+
"status" text not null default 'draft',
|
|
37
|
+
"draft_version_id" uuid,
|
|
38
|
+
"published_version_id" uuid,
|
|
39
|
+
"updated_at" timestamptz not null default now(),
|
|
40
|
+
primary key ("page_id", "locale")
|
|
41
|
+
);
|
|
42
|
+
|
|
37
43
|
-- Sibling reorder for pages: same shape as smoodly_reorder for entries.
|
|
38
44
|
create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
|
|
39
45
|
update "pages" pg set "sort" = u.ord
|
|
@@ -42,30 +48,20 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
|
|
|
42
48
|
and pg."space_id" = "smoodly_current_space"();
|
|
43
49
|
$$;
|
|
44
50
|
|
|
51
|
+
-- A version belongs to ONE locale of one page: each locale has its own
|
|
52
|
+
-- draft, its own publish and its own history.
|
|
45
53
|
create table if not exists "page_versions" (
|
|
46
54
|
"id" uuid primary key default gen_random_uuid(),
|
|
47
55
|
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
48
56
|
"page_id" uuid not null references "pages" ("id") on delete cascade,
|
|
57
|
+
"locale" text not null,
|
|
49
58
|
"tree" jsonb not null,
|
|
50
59
|
"created_by" text,
|
|
51
60
|
"created_at" timestamptz not null default now()
|
|
52
61
|
);
|
|
53
62
|
|
|
54
|
-
create
|
|
55
|
-
"
|
|
56
|
-
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
57
|
-
"title" text not null,
|
|
58
|
-
"fragment" jsonb not null,
|
|
59
|
-
"created_at" timestamptz not null default now()
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
create table if not exists "global_sections" (
|
|
63
|
-
"id" uuid primary key default gen_random_uuid(),
|
|
64
|
-
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
65
|
-
"type" text not null,
|
|
66
|
-
"content" jsonb not null,
|
|
67
|
-
"updated_at" timestamptz not null default now()
|
|
68
|
-
);
|
|
63
|
+
create index if not exists "page_versions_page_locale_idx"
|
|
64
|
+
on "page_versions" ("page_id", "locale", "created_at");
|
|
69
65
|
|
|
70
66
|
create table if not exists "entries" (
|
|
71
67
|
"id" uuid primary key default gen_random_uuid(),
|
|
@@ -73,40 +69,58 @@ create table if not exists "entries" (
|
|
|
73
69
|
"collection" text not null,
|
|
74
70
|
"parent_id" uuid references "entries" ("id") on delete restrict,
|
|
75
71
|
"fields" jsonb not null default '{}'::jsonb,
|
|
76
|
-
"i18n" jsonb,
|
|
77
|
-
"status" text not null default 'draft',
|
|
78
72
|
"sort" numeric,
|
|
79
|
-
"locales" text[],
|
|
80
73
|
"created_at" timestamptz not null default now(),
|
|
81
74
|
"updated_at" timestamptz not null default now()
|
|
82
75
|
);
|
|
83
76
|
|
|
84
|
-
--
|
|
85
|
-
--
|
|
86
|
-
--
|
|
87
|
-
--
|
|
88
|
-
--
|
|
89
|
-
--
|
|
90
|
-
--
|
|
91
|
-
--
|
|
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' <> '');
|
|
100
|
-
|
|
101
|
-
create index if not exists "entries_collection_status_idx"
|
|
102
|
-
on "entries" ("space_id", "collection", "status");
|
|
103
|
-
|
|
77
|
+
-- The node holds identity, tree position and the SHARED fields — every
|
|
78
|
+
-- field without .localized() (spec 2026-09-06 §7): a Person's photo is
|
|
79
|
+
-- the same in every language. Everything a locale owns — its segment,
|
|
80
|
+
-- publish state, the .localized() fields and the version pointers —
|
|
81
|
+
-- lives in entry_locales, one row per locale the entry exists in. There
|
|
82
|
+
-- is no sibling-slug index any more: URL uniqueness per locale is the
|
|
83
|
+
-- primary key of "paths", which the stores write inside the same
|
|
84
|
+
-- operation, and both adapters pre-check siblings per locale in code.
|
|
104
85
|
create index if not exists "entries_collection_sort_idx"
|
|
105
86
|
on "entries" ("space_id", "collection", "sort");
|
|
106
87
|
|
|
107
88
|
create index if not exists "entries_parent_idx"
|
|
108
89
|
on "entries" ("space_id", "collection", "parent_id");
|
|
109
90
|
|
|
91
|
+
create table if not exists "entry_locales" (
|
|
92
|
+
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
93
|
+
"entry_id" uuid not null references "entries" ("id") on delete cascade,
|
|
94
|
+
"locale" text not null,
|
|
95
|
+
"slug" text,
|
|
96
|
+
"status" text not null default 'draft',
|
|
97
|
+
"fields" jsonb not null default '{}'::jsonb,
|
|
98
|
+
"draft_version_id" uuid,
|
|
99
|
+
"published_version_id" uuid,
|
|
100
|
+
"updated_at" timestamptz not null default now(),
|
|
101
|
+
primary key ("entry_id", "locale")
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
create index if not exists "entry_locales_status_idx"
|
|
105
|
+
on "entry_locales" ("space_id", "locale", "status");
|
|
106
|
+
|
|
107
|
+
-- A version belongs to ONE locale of one entry and stores the MERGED
|
|
108
|
+
-- fields (shared + localized + slug) as they were at that save, so
|
|
109
|
+
-- restoring an old Finnish version restores the shared fields too.
|
|
110
|
+
-- Rows exist only for collections registered with `versions: true`.
|
|
111
|
+
create table if not exists "entry_versions" (
|
|
112
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
113
|
+
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
114
|
+
"entry_id" uuid not null references "entries" ("id") on delete cascade,
|
|
115
|
+
"locale" text not null,
|
|
116
|
+
"fields" jsonb not null,
|
|
117
|
+
"created_by" text,
|
|
118
|
+
"created_at" timestamptz not null default now()
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
create index if not exists "entry_versions_entry_locale_idx"
|
|
122
|
+
on "entry_versions" ("entry_id", "locale", "created_at");
|
|
123
|
+
|
|
110
124
|
-- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
|
|
111
125
|
-- statement. Deliberately does not touch updated_at — a drag must not
|
|
112
126
|
-- make the whole collection look freshly edited. Scoped to the current
|
|
@@ -172,10 +186,11 @@ $$;
|
|
|
172
186
|
-- (collectionSQL) run with security_invoker = true, so they are subject
|
|
173
187
|
-- to this same smoodly_read policy rather than their owner's privileges.
|
|
174
188
|
alter table "pages" enable row level security;
|
|
189
|
+
alter table "page_locales" enable row level security;
|
|
175
190
|
alter table "page_versions" enable row level security;
|
|
176
|
-
alter table "saved_sections" enable row level security;
|
|
177
|
-
alter table "global_sections" enable row level security;
|
|
178
191
|
alter table "entries" enable row level security;
|
|
192
|
+
alter table "entry_locales" enable row level security;
|
|
193
|
+
alter table "entry_versions" enable row level security;
|
|
179
194
|
alter table "refs" enable row level security;
|
|
180
195
|
alter table "paths" enable row level security;
|
|
181
196
|
|
|
@@ -272,11 +287,13 @@ begin
|
|
|
272
287
|
end;
|
|
273
288
|
$$;
|
|
274
289
|
|
|
290
|
+
create index if not exists "page_locales_space_idx" on "page_locales" ("space_id");
|
|
291
|
+
|
|
275
292
|
create index if not exists "page_versions_space_idx" on "page_versions" ("space_id");
|
|
276
293
|
|
|
277
|
-
create index if not exists "
|
|
294
|
+
create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_id");
|
|
278
295
|
|
|
279
|
-
create index if not exists "
|
|
296
|
+
create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
|
|
280
297
|
|
|
281
298
|
create index if not exists "refs_space_idx" on "refs" ("space_id");
|
|
282
299
|
|
|
@@ -284,20 +301,24 @@ drop trigger if exists "smoodly_a_set_space" on "pages";
|
|
|
284
301
|
create trigger "smoodly_a_set_space" before insert on "pages"
|
|
285
302
|
for each row execute function "smoodly_set_space"();
|
|
286
303
|
|
|
304
|
+
drop trigger if exists "smoodly_a_set_space" on "page_locales";
|
|
305
|
+
create trigger "smoodly_a_set_space" before insert on "page_locales"
|
|
306
|
+
for each row execute function "smoodly_set_space"();
|
|
307
|
+
|
|
287
308
|
drop trigger if exists "smoodly_a_set_space" on "page_versions";
|
|
288
309
|
create trigger "smoodly_a_set_space" before insert on "page_versions"
|
|
289
310
|
for each row execute function "smoodly_set_space"();
|
|
290
311
|
|
|
291
|
-
drop trigger if exists "smoodly_a_set_space" on "
|
|
292
|
-
create trigger "smoodly_a_set_space" before insert on "
|
|
312
|
+
drop trigger if exists "smoodly_a_set_space" on "entries";
|
|
313
|
+
create trigger "smoodly_a_set_space" before insert on "entries"
|
|
293
314
|
for each row execute function "smoodly_set_space"();
|
|
294
315
|
|
|
295
|
-
drop trigger if exists "smoodly_a_set_space" on "
|
|
296
|
-
create trigger "smoodly_a_set_space" before insert on "
|
|
316
|
+
drop trigger if exists "smoodly_a_set_space" on "entry_locales";
|
|
317
|
+
create trigger "smoodly_a_set_space" before insert on "entry_locales"
|
|
297
318
|
for each row execute function "smoodly_set_space"();
|
|
298
319
|
|
|
299
|
-
drop trigger if exists "smoodly_a_set_space" on "
|
|
300
|
-
create trigger "smoodly_a_set_space" before insert on "
|
|
320
|
+
drop trigger if exists "smoodly_a_set_space" on "entry_versions";
|
|
321
|
+
create trigger "smoodly_a_set_space" before insert on "entry_versions"
|
|
301
322
|
for each row execute function "smoodly_set_space"();
|
|
302
323
|
|
|
303
324
|
drop trigger if exists "smoodly_a_set_space" on "refs";
|
|
@@ -316,20 +337,24 @@ drop trigger if exists "smoodly_b_row_cap" on "pages";
|
|
|
316
337
|
create trigger "smoodly_b_row_cap" before insert on "pages"
|
|
317
338
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
318
339
|
|
|
340
|
+
drop trigger if exists "smoodly_b_row_cap" on "page_locales";
|
|
341
|
+
create trigger "smoodly_b_row_cap" before insert on "page_locales"
|
|
342
|
+
for each row execute function "smoodly_enforce_row_cap"();
|
|
343
|
+
|
|
319
344
|
drop trigger if exists "smoodly_b_row_cap" on "page_versions";
|
|
320
345
|
create trigger "smoodly_b_row_cap" before insert on "page_versions"
|
|
321
346
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
322
347
|
|
|
323
|
-
drop trigger if exists "smoodly_b_row_cap" on "
|
|
324
|
-
create trigger "smoodly_b_row_cap" before insert on "
|
|
348
|
+
drop trigger if exists "smoodly_b_row_cap" on "entries";
|
|
349
|
+
create trigger "smoodly_b_row_cap" before insert on "entries"
|
|
325
350
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
326
351
|
|
|
327
|
-
drop trigger if exists "smoodly_b_row_cap" on "
|
|
328
|
-
create trigger "smoodly_b_row_cap" before insert on "
|
|
352
|
+
drop trigger if exists "smoodly_b_row_cap" on "entry_locales";
|
|
353
|
+
create trigger "smoodly_b_row_cap" before insert on "entry_locales"
|
|
329
354
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
330
355
|
|
|
331
|
-
drop trigger if exists "smoodly_b_row_cap" on "
|
|
332
|
-
create trigger "smoodly_b_row_cap" before insert on "
|
|
356
|
+
drop trigger if exists "smoodly_b_row_cap" on "entry_versions";
|
|
357
|
+
create trigger "smoodly_b_row_cap" before insert on "entry_versions"
|
|
333
358
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
334
359
|
|
|
335
360
|
drop trigger if exists "smoodly_b_row_cap" on "refs";
|
|
@@ -347,6 +372,15 @@ create policy "smoodly_write" on "pages" for all
|
|
|
347
372
|
|
|
348
373
|
drop policy if exists "smoodly_read" on "pages";
|
|
349
374
|
create policy "smoodly_read" on "pages" for select
|
|
375
|
+
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published'));
|
|
376
|
+
|
|
377
|
+
drop policy if exists "smoodly_write" on "page_locales";
|
|
378
|
+
create policy "smoodly_write" on "page_locales" for all
|
|
379
|
+
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
380
|
+
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
381
|
+
|
|
382
|
+
drop policy if exists "smoodly_read" on "page_locales";
|
|
383
|
+
create policy "smoodly_read" on "page_locales" for select
|
|
350
384
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
|
|
351
385
|
|
|
352
386
|
drop policy if exists "smoodly_write" on "page_versions";
|
|
@@ -356,30 +390,34 @@ create policy "smoodly_write" on "page_versions" for all
|
|
|
356
390
|
|
|
357
391
|
drop policy if exists "smoodly_read" on "page_versions";
|
|
358
392
|
create policy "smoodly_read" on "page_versions" for select
|
|
359
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "
|
|
393
|
+
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id"));
|
|
360
394
|
|
|
361
|
-
drop policy if exists "smoodly_write" on "
|
|
362
|
-
create policy "smoodly_write" on "
|
|
395
|
+
drop policy if exists "smoodly_write" on "entries";
|
|
396
|
+
create policy "smoodly_write" on "entries" for all
|
|
363
397
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
364
398
|
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
365
399
|
|
|
366
|
-
drop policy if exists "
|
|
367
|
-
create policy "
|
|
400
|
+
drop policy if exists "smoodly_read" on "entries";
|
|
401
|
+
create policy "smoodly_read" on "entries" for select
|
|
402
|
+
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published'));
|
|
403
|
+
|
|
404
|
+
drop policy if exists "smoodly_write" on "entry_locales";
|
|
405
|
+
create policy "smoodly_write" on "entry_locales" for all
|
|
368
406
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
369
407
|
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
370
408
|
|
|
371
|
-
drop policy if exists "smoodly_read" on "
|
|
372
|
-
create policy "smoodly_read" on "
|
|
373
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()));
|
|
409
|
+
drop policy if exists "smoodly_read" on "entry_locales";
|
|
410
|
+
create policy "smoodly_read" on "entry_locales" for select
|
|
411
|
+
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
|
|
374
412
|
|
|
375
|
-
drop policy if exists "smoodly_write" on "
|
|
376
|
-
create policy "smoodly_write" on "
|
|
413
|
+
drop policy if exists "smoodly_write" on "entry_versions";
|
|
414
|
+
create policy "smoodly_write" on "entry_versions" for all
|
|
377
415
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
378
416
|
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
379
417
|
|
|
380
|
-
drop policy if exists "smoodly_read" on "
|
|
381
|
-
create policy "smoodly_read" on "
|
|
382
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "
|
|
418
|
+
drop policy if exists "smoodly_read" on "entry_versions";
|
|
419
|
+
create policy "smoodly_read" on "entry_versions" for select
|
|
420
|
+
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id"));
|
|
383
421
|
|
|
384
422
|
drop policy if exists "smoodly_write" on "refs";
|
|
385
423
|
create policy "smoodly_write" on "refs" for all
|
|
@@ -393,7 +431,7 @@ create policy "smoodly_write" on "paths" for all
|
|
|
393
431
|
|
|
394
432
|
drop policy if exists "smoodly_read" on "paths";
|
|
395
433
|
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 "
|
|
434
|
+
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (("kind" = 'page' and exists (select 1 from "page_locales" l where l."page_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entry_locales" l where l."entry_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published'))));
|
|
397
435
|
|
|
398
436
|
drop policy if exists "smoodly_write" on "editors";
|
|
399
437
|
create policy "smoodly_write" on "editors" for all
|
|
@@ -402,26 +440,30 @@ create policy "smoodly_write" on "editors" for all
|
|
|
402
440
|
|
|
403
441
|
create or replace view "people" with (security_invoker = true) as
|
|
404
442
|
select
|
|
405
|
-
"id",
|
|
406
|
-
"
|
|
407
|
-
"
|
|
408
|
-
"
|
|
409
|
-
"
|
|
410
|
-
"
|
|
411
|
-
"fields"->>'
|
|
412
|
-
|
|
413
|
-
|
|
443
|
+
e."id",
|
|
444
|
+
l."locale",
|
|
445
|
+
l."slug",
|
|
446
|
+
e."sort",
|
|
447
|
+
e."created_at",
|
|
448
|
+
l."updated_at",
|
|
449
|
+
e."fields"->>'name' as "name",
|
|
450
|
+
l."fields"->>'role' as "role"
|
|
451
|
+
from "entries" e
|
|
452
|
+
join "entry_locales" l on l."entry_id" = e."id"
|
|
453
|
+
where e."collection" = 'people' and l."status" = 'published';
|
|
414
454
|
|
|
415
455
|
create or replace view "articles" with (security_invoker = true) as
|
|
416
456
|
select
|
|
417
|
-
"id",
|
|
418
|
-
"
|
|
419
|
-
"
|
|
420
|
-
"
|
|
421
|
-
"
|
|
422
|
-
"
|
|
423
|
-
"fields"->>'
|
|
424
|
-
"fields"->>'excerpt' as "excerpt",
|
|
425
|
-
"fields"->>'author' as "author"
|
|
426
|
-
from "entries"
|
|
427
|
-
|
|
457
|
+
e."id",
|
|
458
|
+
l."locale",
|
|
459
|
+
l."slug",
|
|
460
|
+
e."sort",
|
|
461
|
+
e."created_at",
|
|
462
|
+
l."updated_at",
|
|
463
|
+
v."fields"->>'title' as "title",
|
|
464
|
+
v."fields"->>'excerpt' as "excerpt",
|
|
465
|
+
v."fields"->>'author' as "author"
|
|
466
|
+
from "entries" e
|
|
467
|
+
join "entry_locales" l on l."entry_id" = e."id"
|
|
468
|
+
join "entry_versions" v on v."id" = l."published_version_id"
|
|
469
|
+
where e."collection" = 'articles' and l."status" = 'published';
|
|
@@ -2,19 +2,30 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { notFound } from "next/navigation";
|
|
3
3
|
import { hasLocale, NextIntlClientProvider } from "next-intl";
|
|
4
4
|
import { routing } from "@/i18n/routing";
|
|
5
|
+
import { Footer } from "@/components/sections/Footer";
|
|
6
|
+
import { contact, footer } from "@/smoodly/shared";
|
|
7
|
+
import { getSmoodlyShared } from "@/smoodly/server";
|
|
5
8
|
|
|
6
9
|
export const metadata = { title: "Smoodly site" };
|
|
7
10
|
|
|
8
11
|
// The site's root layout. The admin has its own under app/(smoodly)/admin —
|
|
9
12
|
// two root layouts, a full page load between them, which is fine: they
|
|
10
|
-
// are separate apps in spirit.
|
|
13
|
+
// are separate apps in spirit. The footer and the contact block are
|
|
14
|
+
// shared content: fetched here, cached per locale under their tag,
|
|
15
|
+
// null until published in this locale.
|
|
11
16
|
export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
|
|
12
17
|
const { locale } = await params;
|
|
13
18
|
if (!hasLocale(routing.locales, locale)) notFound();
|
|
19
|
+
const [footerProps, contactFields] = await Promise.all([
|
|
20
|
+
getSmoodlyShared(footer, { locale }),
|
|
21
|
+
getSmoodlyShared(contact, { locale }),
|
|
22
|
+
]);
|
|
14
23
|
return (
|
|
15
24
|
<html lang={locale}>
|
|
16
25
|
<body>
|
|
17
26
|
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
|
27
|
+
{contactFields && <address>{contactFields.email} · {contactFields.address}</address>}
|
|
28
|
+
{footerProps && <Footer {...footerProps} />}
|
|
18
29
|
</body>
|
|
19
30
|
</html>
|
|
20
31
|
);
|
|
@@ -2,6 +2,7 @@ import { smoodly, z, Zone, type Props } from "smoodly";
|
|
|
2
2
|
import { Hero } from "../sections/Hero";
|
|
3
3
|
import { ArticleList } from "../sections/ArticleList";
|
|
4
4
|
import { CtaBanner } from "../sections/CtaBanner";
|
|
5
|
+
import { Testimonials } from "../sections/Testimonials";
|
|
5
6
|
|
|
6
7
|
// A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
|
|
7
8
|
// `slug` makes it fixed — one record the editor can neither delete nor
|
|
@@ -12,7 +13,7 @@ export const homeSchema = smoodly.schema.page({
|
|
|
12
13
|
slug: "home",
|
|
13
14
|
zones: {
|
|
14
15
|
hero: z.sections([Hero]).max(1),
|
|
15
|
-
body: z.freeform({ sections: [ArticleList] }),
|
|
16
|
+
body: z.freeform({ sections: [ArticleList, Testimonials] }),
|
|
16
17
|
// Locked: fields only. One CtaBanner, always there, never movable.
|
|
17
18
|
prefooter: z.locked(CtaBanner),
|
|
18
19
|
},
|
|
@@ -3,6 +3,7 @@ import { smoodly, z, Zone, type Props } from "smoodly";
|
|
|
3
3
|
import { Hero } from "../sections/Hero";
|
|
4
4
|
import { ArticleList } from "../sections/ArticleList";
|
|
5
5
|
import { CtaBanner } from "../sections/CtaBanner";
|
|
6
|
+
import { Testimonials } from "../sections/Testimonials";
|
|
6
7
|
|
|
7
8
|
// Open registration: editors create as many of these as they like, at
|
|
8
9
|
// any depth the config allows. `page` is where this one sits in the tree.
|
|
@@ -11,7 +12,7 @@ export const pageSchema = smoodly.schema.page({
|
|
|
11
12
|
title: "Page",
|
|
12
13
|
zones: {
|
|
13
14
|
hero: z.sections([Hero]).max(1),
|
|
14
|
-
body: z.freeform({ sections: [ArticleList] }),
|
|
15
|
+
body: z.freeform({ sections: [ArticleList, Testimonials] }),
|
|
15
16
|
prefooter: z.locked(CtaBanner),
|
|
16
17
|
},
|
|
17
18
|
meta: { seo: true },
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// The site footer — a VISUAL shared item (smoodly/shared.ts): registered
|
|
4
|
+
// like any section, stored once, rendered by the locale layout through
|
|
5
|
+
// getSmoodlyShared, never placed in a page tree. The tagline is per
|
|
6
|
+
// language; the copyright line is the same everywhere.
|
|
7
|
+
export const footerSchema = smoodly.schema.section({
|
|
8
|
+
name: "footer",
|
|
9
|
+
title: "Footer",
|
|
10
|
+
description: "The site-wide footer.",
|
|
11
|
+
fields: {
|
|
12
|
+
tagline: f.text().localized(),
|
|
13
|
+
copyright: f.text(),
|
|
14
|
+
},
|
|
15
|
+
styles: {
|
|
16
|
+
tone: f.select(["light", "dark"]).default("dark"),
|
|
17
|
+
},
|
|
18
|
+
sample: {
|
|
19
|
+
tagline: "Made with Smoodly",
|
|
20
|
+
copyright: "© Laniakea Studio",
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function FooterView({ tagline, copyright, styles }: Props<typeof footerSchema>) {
|
|
25
|
+
return (
|
|
26
|
+
<smoodly.Section as="footer" styles={styles}>
|
|
27
|
+
<p>{tagline}</p>
|
|
28
|
+
<small>{copyright}</small>
|
|
29
|
+
</smoodly.Section>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const Footer = smoodly.section(footerSchema, FooterView);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// A testimonials band — placed on pages from the "+" picker's Shared
|
|
4
|
+
// group as a LINKED node (spec 2026-09-07 §4): one row, edited under
|
|
5
|
+
// Shared content, rendered wherever it is placed.
|
|
6
|
+
export const testimonialsSchema = smoodly.schema.section({
|
|
7
|
+
name: "testimonials",
|
|
8
|
+
title: "Testimonials",
|
|
9
|
+
description: "What people say about us.",
|
|
10
|
+
fields: {
|
|
11
|
+
heading: f.text().localized(),
|
|
12
|
+
quotes: f.richtext().localized(),
|
|
13
|
+
},
|
|
14
|
+
styles: {
|
|
15
|
+
tone: f.select(["light", "dark"]).default("light"),
|
|
16
|
+
},
|
|
17
|
+
sample: {
|
|
18
|
+
heading: "Kind words",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function TestimonialsView({ heading, styles }: Props<typeof testimonialsSchema>) {
|
|
23
|
+
return (
|
|
24
|
+
<smoodly.Section styles={styles}>
|
|
25
|
+
<h2>{heading}</h2>
|
|
26
|
+
</smoodly.Section>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const Testimonials = smoodly.section(testimonialsSchema, TestimonialsView);
|
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import { f, smoodly } from "smoodly";
|
|
2
2
|
|
|
3
|
+
// A person's name is the same in every language; the role is translated.
|
|
4
|
+
// Shared fields live on the entry, .localized() ones on its locale rows
|
|
5
|
+
// (spec 2026-09-06 §7).
|
|
3
6
|
export const people = smoodly.collection({
|
|
4
7
|
name: "people",
|
|
5
8
|
title: "People",
|
|
6
9
|
titleField: "name",
|
|
7
10
|
fields: {
|
|
8
11
|
name: f.text(),
|
|
9
|
-
role: f.text().optional(),
|
|
12
|
+
role: f.text().optional().localized(),
|
|
10
13
|
},
|
|
11
14
|
});
|
|
12
15
|
|
|
16
|
+
// Articles keep a version history per locale (`versions: true`): every
|
|
17
|
+
// save is a snapshot, publish moves a pointer, and the published site
|
|
18
|
+
// keeps serving the published snapshot while a draft moves on.
|
|
13
19
|
export const articles = smoodly.collection({
|
|
14
20
|
name: "articles",
|
|
15
21
|
title: "Articles",
|
|
16
22
|
titleField: "title",
|
|
17
23
|
path: "posts",
|
|
24
|
+
versions: true,
|
|
18
25
|
fields: {
|
|
19
|
-
title: f.text(),
|
|
26
|
+
title: f.text().localized(),
|
|
20
27
|
slug: f.slug({ from: "title" }),
|
|
21
|
-
excerpt: f.text().optional(),
|
|
28
|
+
excerpt: f.text().optional().localized(),
|
|
22
29
|
author: f.ref(() => people),
|
|
23
30
|
},
|
|
24
31
|
});
|
|
@@ -2,11 +2,12 @@ import { defineConfig, f } from "smoodly";
|
|
|
2
2
|
import { routing } from "@/i18n/routing";
|
|
3
3
|
import { collections } from "./collections";
|
|
4
4
|
import { sections } from "./sections";
|
|
5
|
+
import { shared } from "./shared";
|
|
5
6
|
import { HomePage } from "@/components/pages/Home";
|
|
6
7
|
import { StandardPage } from "@/components/pages/Page";
|
|
7
8
|
import { ArticlePage } from "@/components/pages/Article";
|
|
8
9
|
|
|
9
|
-
export { collections, sections };
|
|
10
|
+
export { collections, sections, shared };
|
|
10
11
|
|
|
11
12
|
// The registry and the plain settings, one file. Runtime-free: nothing
|
|
12
13
|
// here imports a route file or Next, so a script, the CLI's template
|
|
@@ -18,6 +19,7 @@ export const config = defineConfig({
|
|
|
18
19
|
registry: {
|
|
19
20
|
sections,
|
|
20
21
|
collections,
|
|
22
|
+
shared,
|
|
21
23
|
pages: [HomePage, StandardPage, ArticlePage],
|
|
22
24
|
},
|
|
23
25
|
locales: { default: routing.defaultLocale, supported: [...routing.locales] },
|
|
@@ -4,5 +4,7 @@
|
|
|
4
4
|
import { Hero } from "@/components/sections/Hero";
|
|
5
5
|
import { ArticleList } from "@/components/sections/ArticleList";
|
|
6
6
|
import { CtaBanner } from "@/components/sections/CtaBanner";
|
|
7
|
+
import { Footer } from "@/components/sections/Footer";
|
|
8
|
+
import { Testimonials } from "@/components/sections/Testimonials";
|
|
7
9
|
|
|
8
|
-
export const sections = [Hero, ArticleList, CtaBanner];
|
|
10
|
+
export const sections = [Hero, ArticleList, CtaBanner, Footer, Testimonials];
|
|
@@ -9,7 +9,7 @@ import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admi
|
|
|
9
9
|
import { createPageRenderer, type PageRegistration } from "smoodly/next";
|
|
10
10
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
11
11
|
import { getPathname } from "@/i18n/navigation";
|
|
12
|
-
import { collections, config, sections } from "./config";
|
|
12
|
+
import { collections, config, sections, shared } from "./config";
|
|
13
13
|
|
|
14
14
|
let cached: { db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth } | null = null;
|
|
15
15
|
|
|
@@ -20,7 +20,7 @@ function bound() {
|
|
|
20
20
|
// ONE path index for both stores: pages and entries share the URL space.
|
|
21
21
|
const paths = new SupabasePathIndex(db);
|
|
22
22
|
const locales = config.locales;
|
|
23
|
-
const entries = new SupabaseEntryStore(db, collections, { paths, locales });
|
|
23
|
+
const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared });
|
|
24
24
|
const pages = new SupabasePageStore(db, {
|
|
25
25
|
sections, entries, paths, locales,
|
|
26
26
|
homePageSlug: config.homePageSlug,
|
|
@@ -59,13 +59,14 @@ export const href = (path: string, locale: string) => getPathname({ locale, href
|
|
|
59
59
|
export const site = createSmoodlySite({
|
|
60
60
|
stores,
|
|
61
61
|
collections,
|
|
62
|
+
shared,
|
|
62
63
|
sections,
|
|
63
64
|
locales: config.locales,
|
|
64
65
|
homePageSlug: config.homePageSlug,
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
/** The two route files call these. */
|
|
68
|
-
export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata } = createPageRenderer({
|
|
69
|
+
export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata, getSmoodlyShared } = createPageRenderer({
|
|
69
70
|
site,
|
|
70
71
|
sections,
|
|
71
72
|
pages: (config.registry.pages ?? []) as PageRegistration[],
|