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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "Create a Smoodly site: a fresh Next.js app with the admin mounted, a Supabase schema, an env file and a first editor.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- export default function RootLayout({ children }: { children: ReactNode }) {
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>{children}</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);
@@ -13,7 +13,7 @@
13
13
  "next": "^16.3.4",
14
14
  "react": "^19",
15
15
  "react-dom": "^19",
16
- "smoodly": "0.0.7"
16
+ "smoodly": "0.0.8"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/node": "^26.4.1",
@@ -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);
@@ -13,7 +13,7 @@
13
13
  "next": "^16.3.4",
14
14
  "react": "^19",
15
15
  "react-dom": "^19",
16
- "smoodly": "0.0.7",
16
+ "smoodly": "0.0.8",
17
17
  "next-intl": "^4.14.2"
18
18
  },
19
19
  "devDependencies": {
@@ -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')