create-smoodly-app 0.0.7 → 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 +1 -1
- 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/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 +0 -52
- 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/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 +0 -52
package/package.json
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { Footer } from "@/components/sections/Footer";
|
|
3
|
+
import { contact, footer } from "@/smoodly/shared";
|
|
4
|
+
import { getSmoodlyShared } from "@/smoodly/server";
|
|
2
5
|
|
|
3
6
|
export const metadata = { title: "Smoodly site" };
|
|
4
7
|
|
|
5
|
-
|
|
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. The footer and the contact block are
|
|
11
|
+
// shared content: fetched here, cached under their tag, null until
|
|
12
|
+
// published.
|
|
13
|
+
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
14
|
+
const [footerProps, contactFields] = await Promise.all([
|
|
15
|
+
getSmoodlyShared(footer),
|
|
16
|
+
getSmoodlyShared(contact),
|
|
17
|
+
]);
|
|
6
18
|
return (
|
|
7
19
|
<html lang="en">
|
|
8
|
-
<body>
|
|
20
|
+
<body>
|
|
21
|
+
{children}
|
|
22
|
+
{contactFields && <address>{contactFields.email} · {contactFields.address}</address>}
|
|
23
|
+
{footerProps && <Footer {...footerProps} />}
|
|
24
|
+
</body>
|
|
9
25
|
</html>
|
|
10
26
|
);
|
|
11
27
|
}
|
|
@@ -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,11 +1,12 @@
|
|
|
1
1
|
import { defineConfig, f } from "smoodly";
|
|
2
2
|
import { collections } from "./collections";
|
|
3
3
|
import { sections } from "./sections";
|
|
4
|
+
import { shared } from "./shared";
|
|
4
5
|
import { HomePage } from "@/components/pages/Home";
|
|
5
6
|
import { StandardPage } from "@/components/pages/Page";
|
|
6
7
|
import { ArticlePage } from "@/components/pages/Article";
|
|
7
8
|
|
|
8
|
-
export { collections, sections };
|
|
9
|
+
export { collections, sections, shared };
|
|
9
10
|
|
|
10
11
|
// The registry and the plain settings, one file. Runtime-free: nothing
|
|
11
12
|
// here imports a route file or Next, so a script, the CLI's template
|
|
@@ -14,6 +15,7 @@ export const config = defineConfig({
|
|
|
14
15
|
registry: {
|
|
15
16
|
sections,
|
|
16
17
|
collections,
|
|
18
|
+
shared,
|
|
17
19
|
pages: [HomePage, StandardPage, ArticlePage],
|
|
18
20
|
},
|
|
19
21
|
locales: { default: "en", supported: ["en"] },
|
|
@@ -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];
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
|
|
9
9
|
import { createPageRenderer, type PageRegistration } from "smoodly/next";
|
|
10
10
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
11
|
-
import { collections, config, sections } from "./config";
|
|
11
|
+
import { collections, config, sections, shared } from "./config";
|
|
12
12
|
|
|
13
13
|
let cached: { db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth } | null = null;
|
|
14
14
|
|
|
@@ -19,7 +19,7 @@ function bound() {
|
|
|
19
19
|
// ONE path index for both stores: pages and entries share the URL space.
|
|
20
20
|
const paths = new SupabasePathIndex(db);
|
|
21
21
|
const locales = config.locales;
|
|
22
|
-
const entries = new SupabaseEntryStore(db, collections, { paths, locales });
|
|
22
|
+
const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared });
|
|
23
23
|
const pages = new SupabasePageStore(db, {
|
|
24
24
|
sections, entries, paths, locales,
|
|
25
25
|
homePageSlug: config.homePageSlug,
|
|
@@ -58,13 +58,14 @@ export const href = (path: string, _locale: string) => path;
|
|
|
58
58
|
export const site = createSmoodlySite({
|
|
59
59
|
stores,
|
|
60
60
|
collections,
|
|
61
|
+
shared,
|
|
61
62
|
sections,
|
|
62
63
|
locales: config.locales,
|
|
63
64
|
homePageSlug: config.homePageSlug,
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
/** The two route files call these. */
|
|
67
|
-
export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata } = createPageRenderer({
|
|
68
|
+
export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata, getSmoodlyShared } = createPageRenderer({
|
|
68
69
|
site,
|
|
69
70
|
sections,
|
|
70
71
|
pages: (config.registry.pages ?? []) as PageRegistration[],
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Shared content (spec 2026-09-07): code-declared singletons stored as
|
|
2
|
+
// entries. `footer` is visual — the layout renders it; `contact` is an
|
|
3
|
+
// object the layout reads; `testimonials` is visual and placed on pages.
|
|
4
|
+
import { f, smoodly } from "smoodly";
|
|
5
|
+
import { Footer } from "@/components/sections/Footer";
|
|
6
|
+
import { Testimonials } from "@/components/sections/Testimonials";
|
|
7
|
+
|
|
8
|
+
export const footer = smoodly.shared({ name: "footer", title: "Footer", section: Footer, versions: true });
|
|
9
|
+
|
|
10
|
+
export const contact = smoodly.shared({
|
|
11
|
+
name: "contact",
|
|
12
|
+
title: "Contact details",
|
|
13
|
+
fields: {
|
|
14
|
+
email: f.text(),
|
|
15
|
+
phone: f.text().optional(),
|
|
16
|
+
address: f.text().localized(),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const testimonials = smoodly.shared({ name: "testimonials", title: "Testimonials", section: Testimonials });
|
|
21
|
+
|
|
22
|
+
export const shared = [footer, contact, testimonials];
|
|
@@ -63,22 +63,6 @@ create table if not exists "page_versions" (
|
|
|
63
63
|
create index if not exists "page_versions_page_locale_idx"
|
|
64
64
|
on "page_versions" ("page_id", "locale", "created_at");
|
|
65
65
|
|
|
66
|
-
create table if not exists "saved_sections" (
|
|
67
|
-
"id" uuid primary key default gen_random_uuid(),
|
|
68
|
-
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
69
|
-
"title" text not null,
|
|
70
|
-
"fragment" jsonb not null,
|
|
71
|
-
"created_at" timestamptz not null default now()
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
create table if not exists "global_sections" (
|
|
75
|
-
"id" uuid primary key default gen_random_uuid(),
|
|
76
|
-
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
77
|
-
"type" text not null,
|
|
78
|
-
"content" jsonb not null,
|
|
79
|
-
"updated_at" timestamptz not null default now()
|
|
80
|
-
);
|
|
81
|
-
|
|
82
66
|
create table if not exists "entries" (
|
|
83
67
|
"id" uuid primary key default gen_random_uuid(),
|
|
84
68
|
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
@@ -204,8 +188,6 @@ $$;
|
|
|
204
188
|
alter table "pages" enable row level security;
|
|
205
189
|
alter table "page_locales" enable row level security;
|
|
206
190
|
alter table "page_versions" enable row level security;
|
|
207
|
-
alter table "saved_sections" enable row level security;
|
|
208
|
-
alter table "global_sections" enable row level security;
|
|
209
191
|
alter table "entries" enable row level security;
|
|
210
192
|
alter table "entry_locales" enable row level security;
|
|
211
193
|
alter table "entry_versions" enable row level security;
|
|
@@ -313,10 +295,6 @@ create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_
|
|
|
313
295
|
|
|
314
296
|
create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
|
|
315
297
|
|
|
316
|
-
create index if not exists "saved_sections_space_idx" on "saved_sections" ("space_id");
|
|
317
|
-
|
|
318
|
-
create index if not exists "global_sections_space_idx" on "global_sections" ("space_id");
|
|
319
|
-
|
|
320
298
|
create index if not exists "refs_space_idx" on "refs" ("space_id");
|
|
321
299
|
|
|
322
300
|
drop trigger if exists "smoodly_a_set_space" on "pages";
|
|
@@ -331,14 +309,6 @@ drop trigger if exists "smoodly_a_set_space" on "page_versions";
|
|
|
331
309
|
create trigger "smoodly_a_set_space" before insert on "page_versions"
|
|
332
310
|
for each row execute function "smoodly_set_space"();
|
|
333
311
|
|
|
334
|
-
drop trigger if exists "smoodly_a_set_space" on "saved_sections";
|
|
335
|
-
create trigger "smoodly_a_set_space" before insert on "saved_sections"
|
|
336
|
-
for each row execute function "smoodly_set_space"();
|
|
337
|
-
|
|
338
|
-
drop trigger if exists "smoodly_a_set_space" on "global_sections";
|
|
339
|
-
create trigger "smoodly_a_set_space" before insert on "global_sections"
|
|
340
|
-
for each row execute function "smoodly_set_space"();
|
|
341
|
-
|
|
342
312
|
drop trigger if exists "smoodly_a_set_space" on "entries";
|
|
343
313
|
create trigger "smoodly_a_set_space" before insert on "entries"
|
|
344
314
|
for each row execute function "smoodly_set_space"();
|
|
@@ -375,14 +345,6 @@ drop trigger if exists "smoodly_b_row_cap" on "page_versions";
|
|
|
375
345
|
create trigger "smoodly_b_row_cap" before insert on "page_versions"
|
|
376
346
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
377
347
|
|
|
378
|
-
drop trigger if exists "smoodly_b_row_cap" on "saved_sections";
|
|
379
|
-
create trigger "smoodly_b_row_cap" before insert on "saved_sections"
|
|
380
|
-
for each row execute function "smoodly_enforce_row_cap"();
|
|
381
|
-
|
|
382
|
-
drop trigger if exists "smoodly_b_row_cap" on "global_sections";
|
|
383
|
-
create trigger "smoodly_b_row_cap" before insert on "global_sections"
|
|
384
|
-
for each row execute function "smoodly_enforce_row_cap"();
|
|
385
|
-
|
|
386
348
|
drop trigger if exists "smoodly_b_row_cap" on "entries";
|
|
387
349
|
create trigger "smoodly_b_row_cap" before insert on "entries"
|
|
388
350
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
@@ -430,20 +392,6 @@ drop policy if exists "smoodly_read" on "page_versions";
|
|
|
430
392
|
create policy "smoodly_read" on "page_versions" for select
|
|
431
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"));
|
|
432
394
|
|
|
433
|
-
drop policy if exists "smoodly_write" on "saved_sections";
|
|
434
|
-
create policy "smoodly_write" on "saved_sections" for all
|
|
435
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
436
|
-
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
437
|
-
|
|
438
|
-
drop policy if exists "smoodly_write" on "global_sections";
|
|
439
|
-
create policy "smoodly_write" on "global_sections" for all
|
|
440
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
441
|
-
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
442
|
-
|
|
443
|
-
drop policy if exists "smoodly_read" on "global_sections";
|
|
444
|
-
create policy "smoodly_read" on "global_sections" for select
|
|
445
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()));
|
|
446
|
-
|
|
447
395
|
drop policy if exists "smoodly_write" on "entries";
|
|
448
396
|
create policy "smoodly_write" on "entries" for all
|
|
449
397
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
@@ -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);
|
|
@@ -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[],
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Shared content (spec 2026-09-07): code-declared singletons stored as
|
|
2
|
+
// entries. `footer` is visual — the layout renders it; `contact` is an
|
|
3
|
+
// object the layout reads; `testimonials` is visual and placed on pages.
|
|
4
|
+
import { f, smoodly } from "smoodly";
|
|
5
|
+
import { Footer } from "@/components/sections/Footer";
|
|
6
|
+
import { Testimonials } from "@/components/sections/Testimonials";
|
|
7
|
+
|
|
8
|
+
export const footer = smoodly.shared({ name: "footer", title: "Footer", section: Footer, versions: true });
|
|
9
|
+
|
|
10
|
+
export const contact = smoodly.shared({
|
|
11
|
+
name: "contact",
|
|
12
|
+
title: "Contact details",
|
|
13
|
+
fields: {
|
|
14
|
+
email: f.text(),
|
|
15
|
+
phone: f.text().optional(),
|
|
16
|
+
address: f.text().localized(),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const testimonials = smoodly.shared({ name: "testimonials", title: "Testimonials", section: Testimonials });
|
|
21
|
+
|
|
22
|
+
export const shared = [footer, contact, testimonials];
|
|
@@ -63,22 +63,6 @@ create table if not exists "page_versions" (
|
|
|
63
63
|
create index if not exists "page_versions_page_locale_idx"
|
|
64
64
|
on "page_versions" ("page_id", "locale", "created_at");
|
|
65
65
|
|
|
66
|
-
create table if not exists "saved_sections" (
|
|
67
|
-
"id" uuid primary key default gen_random_uuid(),
|
|
68
|
-
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
69
|
-
"title" text not null,
|
|
70
|
-
"fragment" jsonb not null,
|
|
71
|
-
"created_at" timestamptz not null default now()
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
create table if not exists "global_sections" (
|
|
75
|
-
"id" uuid primary key default gen_random_uuid(),
|
|
76
|
-
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
77
|
-
"type" text not null,
|
|
78
|
-
"content" jsonb not null,
|
|
79
|
-
"updated_at" timestamptz not null default now()
|
|
80
|
-
);
|
|
81
|
-
|
|
82
66
|
create table if not exists "entries" (
|
|
83
67
|
"id" uuid primary key default gen_random_uuid(),
|
|
84
68
|
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
@@ -204,8 +188,6 @@ $$;
|
|
|
204
188
|
alter table "pages" enable row level security;
|
|
205
189
|
alter table "page_locales" enable row level security;
|
|
206
190
|
alter table "page_versions" enable row level security;
|
|
207
|
-
alter table "saved_sections" enable row level security;
|
|
208
|
-
alter table "global_sections" enable row level security;
|
|
209
191
|
alter table "entries" enable row level security;
|
|
210
192
|
alter table "entry_locales" enable row level security;
|
|
211
193
|
alter table "entry_versions" enable row level security;
|
|
@@ -313,10 +295,6 @@ create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_
|
|
|
313
295
|
|
|
314
296
|
create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
|
|
315
297
|
|
|
316
|
-
create index if not exists "saved_sections_space_idx" on "saved_sections" ("space_id");
|
|
317
|
-
|
|
318
|
-
create index if not exists "global_sections_space_idx" on "global_sections" ("space_id");
|
|
319
|
-
|
|
320
298
|
create index if not exists "refs_space_idx" on "refs" ("space_id");
|
|
321
299
|
|
|
322
300
|
drop trigger if exists "smoodly_a_set_space" on "pages";
|
|
@@ -331,14 +309,6 @@ drop trigger if exists "smoodly_a_set_space" on "page_versions";
|
|
|
331
309
|
create trigger "smoodly_a_set_space" before insert on "page_versions"
|
|
332
310
|
for each row execute function "smoodly_set_space"();
|
|
333
311
|
|
|
334
|
-
drop trigger if exists "smoodly_a_set_space" on "saved_sections";
|
|
335
|
-
create trigger "smoodly_a_set_space" before insert on "saved_sections"
|
|
336
|
-
for each row execute function "smoodly_set_space"();
|
|
337
|
-
|
|
338
|
-
drop trigger if exists "smoodly_a_set_space" on "global_sections";
|
|
339
|
-
create trigger "smoodly_a_set_space" before insert on "global_sections"
|
|
340
|
-
for each row execute function "smoodly_set_space"();
|
|
341
|
-
|
|
342
312
|
drop trigger if exists "smoodly_a_set_space" on "entries";
|
|
343
313
|
create trigger "smoodly_a_set_space" before insert on "entries"
|
|
344
314
|
for each row execute function "smoodly_set_space"();
|
|
@@ -375,14 +345,6 @@ drop trigger if exists "smoodly_b_row_cap" on "page_versions";
|
|
|
375
345
|
create trigger "smoodly_b_row_cap" before insert on "page_versions"
|
|
376
346
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
377
347
|
|
|
378
|
-
drop trigger if exists "smoodly_b_row_cap" on "saved_sections";
|
|
379
|
-
create trigger "smoodly_b_row_cap" before insert on "saved_sections"
|
|
380
|
-
for each row execute function "smoodly_enforce_row_cap"();
|
|
381
|
-
|
|
382
|
-
drop trigger if exists "smoodly_b_row_cap" on "global_sections";
|
|
383
|
-
create trigger "smoodly_b_row_cap" before insert on "global_sections"
|
|
384
|
-
for each row execute function "smoodly_enforce_row_cap"();
|
|
385
|
-
|
|
386
348
|
drop trigger if exists "smoodly_b_row_cap" on "entries";
|
|
387
349
|
create trigger "smoodly_b_row_cap" before insert on "entries"
|
|
388
350
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
@@ -430,20 +392,6 @@ drop policy if exists "smoodly_read" on "page_versions";
|
|
|
430
392
|
create policy "smoodly_read" on "page_versions" for select
|
|
431
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"));
|
|
432
394
|
|
|
433
|
-
drop policy if exists "smoodly_write" on "saved_sections";
|
|
434
|
-
create policy "smoodly_write" on "saved_sections" for all
|
|
435
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
436
|
-
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
437
|
-
|
|
438
|
-
drop policy if exists "smoodly_write" on "global_sections";
|
|
439
|
-
create policy "smoodly_write" on "global_sections" for all
|
|
440
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
441
|
-
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
442
|
-
|
|
443
|
-
drop policy if exists "smoodly_read" on "global_sections";
|
|
444
|
-
create policy "smoodly_read" on "global_sections" for select
|
|
445
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()));
|
|
446
|
-
|
|
447
395
|
drop policy if exists "smoodly_write" on "entries";
|
|
448
396
|
create policy "smoodly_write" on "entries" for all
|
|
449
397
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|