create-smoodly-app 0.0.10 → 0.0.12

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.
Files changed (29) hide show
  1. package/README.md +5 -2
  2. package/dist/index.js +1 -1
  3. package/package.json +1 -1
  4. package/templates/next/app/globals.css +5 -5
  5. package/templates/next/components/ArticleCard.tsx +2 -1
  6. package/templates/next/components/articles.ts +2 -1
  7. package/templates/next/components/pages/Article.tsx +2 -1
  8. package/templates/next/components/sections/Hero.tsx +3 -3
  9. package/templates/next/components/sections/ImageText.tsx +4 -3
  10. package/templates/next/components/sections/PageHero.tsx +3 -2
  11. package/templates/next/next.config.ts +4 -1
  12. package/templates/next/package.json +1 -1
  13. package/templates/next/scripts/seed.ts +27 -7
  14. package/templates/next/smoodly/collections.ts +1 -1
  15. package/templates/next/smoodly/server.ts +8 -6
  16. package/templates/next/supabase/migrations/00000000000000_smoodly.sql +60 -0
  17. package/templates/next-intl/app/globals.css +5 -5
  18. package/templates/next-intl/components/ArticleCard.tsx +2 -1
  19. package/templates/next-intl/components/articles.ts +2 -1
  20. package/templates/next-intl/components/pages/Article.tsx +2 -1
  21. package/templates/next-intl/components/sections/Hero.tsx +3 -3
  22. package/templates/next-intl/components/sections/ImageText.tsx +4 -3
  23. package/templates/next-intl/components/sections/PageHero.tsx +3 -2
  24. package/templates/next-intl/next.config.ts +4 -1
  25. package/templates/next-intl/package.json +1 -1
  26. package/templates/next-intl/scripts/seed.ts +27 -7
  27. package/templates/next-intl/smoodly/collections.ts +1 -1
  28. package/templates/next-intl/smoodly/server.ts +8 -6
  29. package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +60 -0
package/README.md CHANGED
@@ -15,8 +15,8 @@ sign in. Spec: `docs/superpowers/specs/2026-09-04-create-smoodly-app-design.md`.
15
15
  `proxy.ts`, translated URLs per locale. Flag: `--locales en,fi`.
16
16
  3. Where content lives: a **local Supabase stack** (`supabase init` +
17
17
  `supabase start` inside the app, with the services Smoodly never uses
18
- excluded — storage, image proxy, edge functions, analytics, realtime,
19
- the pooler — so the stack stays small beside any other you run; needs
18
+ excluded — the image proxy, edge functions, analytics, realtime, the
19
+ pooler — so the stack stays small beside any other you run; needs
20
20
  the Supabase CLI and Docker; when
21
21
  another project holds the default `543xx` ports the new one moves to the
22
22
  next free block, `553xx` and so on, and says so) or a
@@ -86,6 +86,9 @@ multilingual one with `--locales en,de`.
86
86
  app's stack with `-x storage-api,imgproxy,edge-runtime,logflare,vector,realtime,supavisor`
87
87
  (`LOCAL_START_ARGS`), and the "Finish by hand" text carries the same
88
88
  flags. Kept: db, kong, auth, PostgREST, Studio and pg-meta, mailpit.
89
+ Storage is back in the local stack since the media library
90
+ (2026-09-08); the health-check flake is a Docker memory problem, see
91
+ the repo's memory note.
89
92
  - **A non-interactive run without `--locales` needs `--yes`.** The
90
93
  "Multilingual site?" prompt is asked whenever `--locales` is absent, and
91
94
  with no TTY (a CI step, stdin at EOF) the CLI exits 13 having created
package/dist/index.js CHANGED
@@ -392,7 +392,7 @@ function parseStatusEnv(output) {
392
392
  }
393
393
 
394
394
  // src/run.ts
395
- var LOCAL_START_ARGS = ["start", "-x", "storage-api,imgproxy,edge-runtime,logflare,vector,realtime,supavisor"];
395
+ var LOCAL_START_ARGS = ["start", "-x", "imgproxy,edge-runtime,logflare,vector,realtime,supavisor"];
396
396
  async function run(answers2, deps) {
397
397
  if (answers2.supabase === "hosted" && !answers2.hosted) {
398
398
  throw new Error("hosted answers need the project's keys");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
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": {
@@ -94,7 +94,7 @@ main > section { padding-block: 56px; }
94
94
  .hero__heading { font-family: var(--serif); font-weight: 400; font-size: clamp(40px, 5vw, 68px); line-height: 1.05; letter-spacing: -0.02em; text-wrap: balance; }
95
95
  .hero__text { font-size: 18px; line-height: 1.6; color: var(--muted); max-width: 520px; text-wrap: pretty; }
96
96
  .hero__buttons { display: flex; gap: 12px; flex-wrap: wrap; }
97
- .hero__image { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; border-radius: var(--radius); }
97
+ .hero__image { width: 100%; border-radius: var(--radius); }
98
98
 
99
99
  /* ---------- page hero ---------- */
100
100
 
@@ -102,12 +102,12 @@ main > section { padding-block: 56px; }
102
102
  .page-hero__copy { display: flex; flex-direction: column; gap: 20px; max-width: 760px; }
103
103
  .page-hero__heading { font-family: var(--serif); font-weight: 400; font-size: clamp(40px, 5vw, 64px); line-height: 1.05; letter-spacing: -0.02em; text-wrap: balance; }
104
104
  .page-hero__text { font-size: 18px; line-height: 1.6; color: var(--muted); text-wrap: pretty; }
105
- .page-hero__image { width: 100%; aspect-ratio: 21 / 9; object-fit: cover; border-radius: var(--radius); }
105
+ .page-hero__image { width: 100%; border-radius: var(--radius); }
106
106
 
107
107
  /* ---------- image and text ---------- */
108
108
 
109
109
  .image-text__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 48px; align-items: center; }
110
- .image-text__image { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius); }
110
+ .image-text__image { width: 100%; border-radius: var(--radius); }
111
111
  [data-image-side="right"] .image-text__image { order: 2; }
112
112
  .image-text__copy { display: flex; flex-direction: column; gap: 20px; }
113
113
  .image-text__grid > .h2 { align-self: start; }
@@ -136,7 +136,7 @@ main > section { padding-block: 56px; }
136
136
  .card { display: flex; flex-direction: column; gap: 14px; color: inherit; }
137
137
  .card:hover { color: inherit; }
138
138
  .card:hover .card__title { color: var(--accent); }
139
- .card__image { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; border-radius: var(--radius); }
139
+ .card__image { width: 100%; border-radius: var(--radius); }
140
140
  .card__meta { display: flex; gap: 10px; font-size: 13px; color: var(--muted); }
141
141
  .card__tag { color: var(--accent); font-weight: 600; }
142
142
  .card__title { font-family: var(--serif); font-weight: 500; font-size: 26px; line-height: 1.2; letter-spacing: -0.01em; text-wrap: balance; }
@@ -169,7 +169,7 @@ h3.card__title { font-size: 24px; }
169
169
  .article__byline { display: flex; align-items: center; gap: 12px; font-size: 14px; color: var(--muted); }
170
170
  .article__byline strong { color: var(--ink); font-weight: 500; }
171
171
  .article__avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--line); display: inline-block; flex: none; }
172
- .article__image { width: 100%; aspect-ratio: 21 / 9; object-fit: cover; border-radius: var(--radius); }
172
+ .article__image { width: 100%; border-radius: var(--radius); }
173
173
  .article__body { max-width: 720px; margin: 16px auto 0; width: 100%; }
174
174
 
175
175
  .related { border-top: 1px solid var(--line); margin-top: 88px; padding: 64px 0 96px; }
@@ -1,3 +1,4 @@
1
+ import { SmoodlyImage } from "smoodly/image";
1
2
  import { formatDate, type ArticleFields } from "./articles";
2
3
 
3
4
  // One article card: photo, first tag and date, title, excerpt. The grid
@@ -10,7 +11,7 @@ export function ArticleCard({ article, url, locale, compact = false }: {
10
11
  const Title = compact ? "h3" : "h2";
11
12
  return (
12
13
  <a className="card" href={url}>
13
- {article.image?.url && <img className="card__image" src={article.image.url} alt={article.image.alt ?? ""} />}
14
+ {article.image && <SmoodlyImage image={article.image} className="card__image" sizes="(min-width: 900px) 33vw, 100vw" />}
14
15
  <div className="card__meta">
15
16
  {article.tags?.[0] && <span className="card__tag">{article.tags[0]}</span>}
16
17
  {article.date && <span>{formatDate(article.date, locale)}</span>}
@@ -1,6 +1,7 @@
1
1
  // Small helpers shared by the Articles index, the article page and the
2
2
  // Favourite articles section — the article's URL, dates in the request
3
3
  // locale, and the read time from the richtext body.
4
+ import type { ResolvedImage } from "smoodly";
4
5
  import { articles } from "@/smoodly/collections";
5
6
 
6
7
  export type ArticleFields = {
@@ -9,7 +10,7 @@ export type ArticleFields = {
9
10
  excerpt?: string;
10
11
  body?: unknown;
11
12
  tags?: string[];
12
- image?: { url: string; alt?: string };
13
+ image?: ResolvedImage;
13
14
  date: string;
14
15
  };
15
16
 
@@ -1,4 +1,5 @@
1
1
  import { RichText, smoodly, type EntryPageProps } from "smoodly";
2
+ import { SmoodlyImage } from "smoodly/image";
2
3
  import { articles } from "@/smoodly/collections";
3
4
  import { site } from "@/smoodly/server";
4
5
  import { ArticleCard } from "../ArticleCard";
@@ -37,7 +38,7 @@ async function ArticleView({ entry }: EntryPageProps) {
37
38
  </span>
38
39
  </p>
39
40
  </div>
40
- {a.image?.url && <img className="article__image" src={a.image.url} alt={a.image.alt ?? ""} />}
41
+ {a.image && <SmoodlyImage image={a.image} aspect="21:9" className="article__image" sizes="(min-width: 900px) 800px, 100vw" priority />}
41
42
  <div className="article__body prose prose--lead"><RichText doc={a.body} /></div>
42
43
  </article>
43
44
  {related.length > 0 && (
@@ -1,4 +1,5 @@
1
1
  import { f, smoodly, type Props } from "smoodly";
2
+ import { SmoodlyImage } from "smoodly/image";
2
3
 
3
4
  // The home page opener: kicker, heading, text, up to two buttons and a
4
5
  // portrait photo. Home locks it (`z.locked(Hero)`): one node an editor
@@ -13,14 +14,13 @@ export const heroSchema = smoodly.schema.section({
13
14
  heading: f.text(),
14
15
  text: f.text(),
15
16
  buttons: f.list(f.object({ label: f.text(), href: f.link() })).max(2),
16
- image: f.image(),
17
+ image: f.image({ aspect: "4:5" }),
17
18
  },
18
19
  sample: {
19
20
  kicker: "Lorem ipsum dolor",
20
21
  heading: "Consectetur adipiscing elit sed do eiusmod",
21
22
  text: "Tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.",
22
23
  buttons: [{ label: "About us", href: "/company/about-us" }],
23
- image: { url: "/images/hero_image.jpg", alt: "Office interior" },
24
24
  },
25
25
  });
26
26
 
@@ -40,7 +40,7 @@ function HeroView({ kicker, heading, text, buttons, image, styles }: Props<typeo
40
40
  </div>
41
41
  )}
42
42
  </div>
43
- {image?.url && <img className="hero__image" src={image.url} alt={image.alt ?? ""} />}
43
+ {image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority />}
44
44
  </div>
45
45
  </smoodly.Section>
46
46
  );
@@ -1,4 +1,5 @@
1
1
  import { f, RichText, smoodly, type Props } from "smoodly";
2
+ import { SmoodlyImage } from "smoodly/image";
2
3
 
3
4
  // A photo beside a heading and richtext, the photo on either side (a
4
5
  // style, not a field: styles are never translated). Without a photo the
@@ -10,7 +11,7 @@ export const imageTextSchema = smoodly.schema.section({
10
11
  title: "Image and text",
11
12
  description: "A photo beside a heading and text; text alone in two columns without one.",
12
13
  fields: {
13
- image: f.image().optional(),
14
+ image: f.image({ aspect: "4:3" }).optional(),
14
15
  heading: f.text(),
15
16
  // Body copy beside a photo: emphasis and links, no headings — the block's
16
17
  // own heading field owns that. Every other richtext field takes the default.
@@ -39,9 +40,9 @@ function ImageTextView({ image, heading, text, link, styles }: Props<typeof imag
39
40
  return (
40
41
  <smoodly.Section styles={styles} className="image-text">
41
42
  <div className="container image-text__grid">
42
- {image?.url ? (
43
+ {image ? (
43
44
  <>
44
- <img className="image-text__image" src={image.url} alt={image.alt ?? ""} />
45
+ <SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" />
45
46
  <div className="image-text__copy">
46
47
  <h2 className="h2">{heading}</h2>
47
48
  {body}
@@ -1,4 +1,5 @@
1
1
  import { f, smoodly, type Props } from "smoodly";
2
+ import { SmoodlyImage } from "smoodly/image";
2
3
 
3
4
  // The opener of every page that is not the home page: kicker, heading,
4
5
  // text and an optional wide photo. Page and Articles lock it — each page
@@ -12,7 +13,7 @@ export const pageHeroSchema = smoodly.schema.section({
12
13
  kicker: f.text().optional(),
13
14
  heading: f.text(),
14
15
  text: f.text(),
15
- image: f.image().optional(),
16
+ image: f.image({ aspect: "21:9" }).optional(),
16
17
  },
17
18
  sample: {
18
19
  heading: "About us",
@@ -29,7 +30,7 @@ function PageHeroView({ kicker, heading, text, image, styles }: Props<typeof pag
29
30
  <h1 className="page-hero__heading">{heading}</h1>
30
31
  <p className="page-hero__text">{text}</p>
31
32
  </div>
32
- {image?.url && <img className="page-hero__image" src={image.url} alt={image.alt ?? ""} />}
33
+ {image && <SmoodlyImage image={image} className="page-hero__image" sizes="100vw" priority />}
33
34
  </div>
34
35
  </smoodly.Section>
35
36
  );
@@ -1,5 +1,8 @@
1
1
  import type { NextConfig } from "next";
2
+ import { smoodlyImages } from "smoodly/image/config";
2
3
 
3
- const config: NextConfig = {};
4
+ // next/image may only optimize from the Supabase host named in SMOODLY_URL
5
+ // (a local stack also needs Next's local-IP allowance; the helper sets it).
6
+ const config: NextConfig = { images: smoodlyImages() };
4
7
 
5
8
  export default config;
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.10"
17
+ "smoodly": "0.0.12"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -14,8 +14,9 @@
14
14
  // both; what a reader perceives as language (nav, headings, tags,
15
15
  // roles, footer titles) is translated. Idempotent: exits once the home
16
16
  // page exists.
17
+ import { readFile } from "node:fs/promises";
17
18
  import next from "@next/env";
18
- import type { PageTree } from "smoodly";
19
+ import { imageDimensions, type PageTree } from "smoodly";
19
20
  const { loadEnvConfig } = next;
20
21
 
21
22
  loadEnvConfig(process.cwd());
@@ -23,7 +24,7 @@ loadEnvConfig(process.cwd());
23
24
  const { config } = await import("../smoodly/config");
24
25
  const { articles } = await import("../smoodly/collections");
25
26
  const { href, stores } = await import("../smoodly/server");
26
- const { pages, entries } = stores();
27
+ const { pages, entries, assets } = stores();
27
28
 
28
29
  const EN = config.locales.default;
29
30
  const FI = config.locales.supported.includes("fi") && EN !== "fi" ? "fi" : null;
@@ -46,7 +47,26 @@ const quoteBlock = (s: string): Node => ({ type: "blockquote", content: [p(s)] }
46
47
  const bullets = (...items: string[]): Node => ({ type: "bulletList", content: items.map((s) => ({ type: "listItem", content: [p(s)] })) });
47
48
  const doc = (...content: Node[]) => ({ type: "doc", content });
48
49
 
49
- const image = (name: string, alt: string) => ({ url: `/images/${name}.jpg`, alt });
50
+ // The starter's photos become assets: each file is uploaded once through
51
+ // the store (rows in `assets`, bytes in the `media` bucket), and a field
52
+ // value is `{ asset: <id> }` — the alt lives on the asset (spec 2026-09-08).
53
+ const uploaded = new Map<string, string>();
54
+ async function image(name: string, alt: string): Promise<{ asset: string }> {
55
+ let id = uploaded.get(name);
56
+ if (!id) {
57
+ const bytes = new Uint8Array(await readFile(`public/images/${name}.jpg`));
58
+ const dims = imageDimensions(bytes) ?? {};
59
+ id = (await assets.create({ filename: `${name}.jpg`, mime: "image/jpeg", size: bytes.byteLength, bytes, alt, title: alt, ...dims })).id;
60
+ uploaded.set(name, id);
61
+ }
62
+ return { asset: id };
63
+ }
64
+ const img = {
65
+ article: await image("article_photo", "Notebook on a desk"),
66
+ home2: await image("home_section_2", "Team working"),
67
+ hero: await image("hero_image", "Office interior"),
68
+ about: await image("about_main", "Team meeting"),
69
+ };
50
70
 
51
71
  /** The articles collection's URL segment in a locale, from its own `path`. */
52
72
  const articlesSegment = (locale: string) => {
@@ -136,7 +156,7 @@ const article = await seedEntry("articles", {
136
156
  excerpt: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
137
157
  body,
138
158
  tags: ["Insights", "Design"],
139
- image: image("article_photo", "Notebook on a desk"),
159
+ image: img.article,
140
160
  date: "2026-09-02",
141
161
  author: jane.id,
142
162
  }, {
@@ -190,7 +210,7 @@ const imageText = (locale: string, linkLabel: string): Node => ({
190
210
  id: "node-intro",
191
211
  type: "imageText",
192
212
  fields: {
193
- image: image("home_section_2", "Team working"),
213
+ image: img.home2,
194
214
  heading: "Duis aute irure dolor in reprehenderit",
195
215
  text: doc(
196
216
  p("In voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."),
@@ -236,7 +256,7 @@ const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]
236
256
  { label: t.buttons[0], href: paths(locale).about },
237
257
  { label: t.buttons[1], href: paths(locale).articles },
238
258
  ],
239
- image: image("hero_image", "Office interior"),
259
+ image: img.hero,
240
260
  },
241
261
  }],
242
262
  body: [imageText(locale, t.link), quote, points, favourites(t.favourites)],
@@ -279,7 +299,7 @@ const aboutTree = (t: { kicker: string; people: string }): Tree => ({
279
299
  kicker: t.kicker,
280
300
  heading: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
281
301
  text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
282
- image: image("about_main", "Team meeting"),
302
+ image: img.about,
283
303
  },
284
304
  }],
285
305
  body: [
@@ -33,7 +33,7 @@ export const articles = smoodly.collection({
33
33
  excerpt: f.text().localized(),
34
34
  body: f.richtext().localized(),
35
35
  tags: f.list(f.text()).localized(),
36
- image: f.image(),
36
+ image: f.image({ aspect: "3:2" }),
37
37
  date: f.date(),
38
38
  author: f.ref(() => people),
39
39
  },
@@ -9,7 +9,7 @@
9
9
  // admin route enters it from config.ts — so nothing here may read the
10
10
  // config at module scope.
11
11
  import {
12
- createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
12
+ createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
13
13
  } from "smoodly";
14
14
  import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
15
15
  import { createPageRenderer, type PageRegistration } from "smoodly/next";
@@ -18,7 +18,7 @@ import { collections, config, sections, shared } from "./config";
18
18
 
19
19
  type Renderer = ReturnType<typeof createPageRenderer>;
20
20
  type Bound = {
21
- db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer;
21
+ db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer;
22
22
  };
23
23
 
24
24
  let cached: Bound | null = null;
@@ -36,10 +36,12 @@ function bound(): Bound {
36
36
  homePageSlug: config.homePageSlug,
37
37
  tree: config.pages?.tree,
38
38
  });
39
+ // The media library: rows in `assets`, bytes in the public `media` bucket.
40
+ const assets = new SupabaseAssetStore(db);
39
41
  const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
40
42
  // Shadows the exported `stores` below on purpose: `createSmoodlySite`
41
43
  // takes this local closure, not the export, so it never re-enters `bound()`.
42
- const stores = () => ({ pages, entries });
44
+ const stores = () => ({ pages, entries, assets });
43
45
  // The site layer: (locale, path) → page or entry, and the content API
44
46
  // (site.getPagesTree for menus, site.getEntries for lists).
45
47
  // Shadows the exported `site` below on purpose: `createPageRenderer`
@@ -51,13 +53,13 @@ function bound(): Bound {
51
53
  pages: (config.registry.pages ?? []) as PageRegistration[],
52
54
  href,
53
55
  });
54
- cached = { db, pages, entries, auth, site, renderer };
56
+ cached = { db, pages, entries, assets, auth, site, renderer };
55
57
  return cached;
56
58
  }
57
59
 
58
60
  export function stores() {
59
- const { pages, entries } = bound();
60
- return { pages, entries };
61
+ const { pages, entries, assets } = bound();
62
+ return { pages, entries, assets };
61
63
  }
62
64
 
63
65
  /** The server-side client (seed script, one-off admin tasks). */
@@ -164,6 +164,35 @@ create table if not exists "paths" (
164
164
  create index if not exists "paths_target_idx"
165
165
  on "paths" ("space_id", "kind", "target_id");
166
166
 
167
+ -- Media (spec 2026-09-08): one row per upload, bytes in the "media"
168
+ -- bucket at "path". Immediate — never draft-gated, never versioned —
169
+ -- so there is no locale row and no version table. status 'pending' is
170
+ -- a row whose signed upload has not been confirmed; list never shows it.
171
+ create table if not exists "assets" (
172
+ "id" uuid primary key default gen_random_uuid(),
173
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
174
+ "kind" text not null check ("kind" in ('image', 'video', 'file')),
175
+ "status" text not null default 'pending' check ("status" in ('pending', 'ready')),
176
+ "path" text not null,
177
+ "mime" text not null,
178
+ "size" bigint not null,
179
+ "width" integer,
180
+ "height" integer,
181
+ "duration" numeric,
182
+ "poster_id" uuid references "assets" ("id") on delete set null,
183
+ "filename" text not null,
184
+ "title" text,
185
+ "alt" text,
186
+ "caption" text,
187
+ "credit" text,
188
+ "created_by" text,
189
+ "created_at" timestamptz not null default now(),
190
+ "updated_at" timestamptz not null default now()
191
+ );
192
+
193
+ create index if not exists "assets_space_created_idx"
194
+ on "assets" ("space_id", "status", "created_at" desc);
195
+
167
196
  -- Replace the targets' rows with the given rows in ONE call: a SQL
168
197
  -- function runs in the caller's transaction, so a primary-key collision
169
198
  -- rolls the whole rewrite back. Inserted rows get their space from the
@@ -193,6 +222,7 @@ alter table "entry_locales" enable row level security;
193
222
  alter table "entry_versions" enable row level security;
194
223
  alter table "refs" enable row level security;
195
224
  alter table "paths" enable row level security;
225
+ alter table "assets" enable row level security;
196
226
 
197
227
  -- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
198
228
  -- for everyone with ONE definition. Self-host fills them by hand or
@@ -329,6 +359,10 @@ drop trigger if exists "smoodly_a_set_space" on "paths";
329
359
  create trigger "smoodly_a_set_space" before insert on "paths"
330
360
  for each row execute function "smoodly_set_space"();
331
361
 
362
+ drop trigger if exists "smoodly_a_set_space" on "assets";
363
+ create trigger "smoodly_a_set_space" before insert on "assets"
364
+ for each row execute function "smoodly_set_space"();
365
+
332
366
  drop trigger if exists "smoodly_a_set_space" on "editors";
333
367
  create trigger "smoodly_a_set_space" before insert on "editors"
334
368
  for each row execute function "smoodly_set_space"();
@@ -365,6 +399,10 @@ drop trigger if exists "smoodly_b_row_cap" on "paths";
365
399
  create trigger "smoodly_b_row_cap" before insert on "paths"
366
400
  for each row execute function "smoodly_enforce_row_cap"();
367
401
 
402
+ drop trigger if exists "smoodly_b_row_cap" on "assets";
403
+ create trigger "smoodly_b_row_cap" before insert on "assets"
404
+ for each row execute function "smoodly_enforce_row_cap"();
405
+
368
406
  drop policy if exists "smoodly_write" on "pages";
369
407
  create policy "smoodly_write" on "pages" for all
370
408
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
@@ -433,11 +471,33 @@ drop policy if exists "smoodly_read" on "paths";
433
471
  create policy "smoodly_read" on "paths" for select
434
472
  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'))));
435
473
 
474
+ drop policy if exists "smoodly_write" on "assets";
475
+ create policy "smoodly_write" on "assets" for all
476
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
477
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
478
+
479
+ drop policy if exists "smoodly_read" on "assets";
480
+ create policy "smoodly_read" on "assets" for select
481
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'ready');
482
+
436
483
  drop policy if exists "smoodly_write" on "editors";
437
484
  create policy "smoodly_write" on "editors" for all
438
485
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
439
486
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
440
487
 
488
+ insert into storage.buckets ("id", "name", "public", "file_size_limit")
489
+ values ('media', 'media', true, 104857600)
490
+ on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
491
+
492
+ drop policy if exists "smoodly_media_read" on storage.objects;
493
+ create policy "smoodly_media_read" on storage.objects for select
494
+ using ("bucket_id" = 'media');
495
+
496
+ drop policy if exists "smoodly_media_write" on storage.objects;
497
+ create policy "smoodly_media_write" on storage.objects for all
498
+ using ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text)
499
+ with check ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text);
500
+
441
501
  create or replace view "people" with (security_invoker = true) as
442
502
  select
443
503
  e."id",
@@ -94,7 +94,7 @@ main > section { padding-block: 56px; }
94
94
  .hero__heading { font-family: var(--serif); font-weight: 400; font-size: clamp(40px, 5vw, 68px); line-height: 1.05; letter-spacing: -0.02em; text-wrap: balance; }
95
95
  .hero__text { font-size: 18px; line-height: 1.6; color: var(--muted); max-width: 520px; text-wrap: pretty; }
96
96
  .hero__buttons { display: flex; gap: 12px; flex-wrap: wrap; }
97
- .hero__image { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; border-radius: var(--radius); }
97
+ .hero__image { width: 100%; border-radius: var(--radius); }
98
98
 
99
99
  /* ---------- page hero ---------- */
100
100
 
@@ -102,12 +102,12 @@ main > section { padding-block: 56px; }
102
102
  .page-hero__copy { display: flex; flex-direction: column; gap: 20px; max-width: 760px; }
103
103
  .page-hero__heading { font-family: var(--serif); font-weight: 400; font-size: clamp(40px, 5vw, 64px); line-height: 1.05; letter-spacing: -0.02em; text-wrap: balance; }
104
104
  .page-hero__text { font-size: 18px; line-height: 1.6; color: var(--muted); text-wrap: pretty; }
105
- .page-hero__image { width: 100%; aspect-ratio: 21 / 9; object-fit: cover; border-radius: var(--radius); }
105
+ .page-hero__image { width: 100%; border-radius: var(--radius); }
106
106
 
107
107
  /* ---------- image and text ---------- */
108
108
 
109
109
  .image-text__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 48px; align-items: center; }
110
- .image-text__image { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius); }
110
+ .image-text__image { width: 100%; border-radius: var(--radius); }
111
111
  [data-image-side="right"] .image-text__image { order: 2; }
112
112
  .image-text__copy { display: flex; flex-direction: column; gap: 20px; }
113
113
  .image-text__grid > .h2 { align-self: start; }
@@ -136,7 +136,7 @@ main > section { padding-block: 56px; }
136
136
  .card { display: flex; flex-direction: column; gap: 14px; color: inherit; }
137
137
  .card:hover { color: inherit; }
138
138
  .card:hover .card__title { color: var(--accent); }
139
- .card__image { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; border-radius: var(--radius); }
139
+ .card__image { width: 100%; border-radius: var(--radius); }
140
140
  .card__meta { display: flex; gap: 10px; font-size: 13px; color: var(--muted); }
141
141
  .card__tag { color: var(--accent); font-weight: 600; }
142
142
  .card__title { font-family: var(--serif); font-weight: 500; font-size: 26px; line-height: 1.2; letter-spacing: -0.01em; text-wrap: balance; }
@@ -169,7 +169,7 @@ h3.card__title { font-size: 24px; }
169
169
  .article__byline { display: flex; align-items: center; gap: 12px; font-size: 14px; color: var(--muted); }
170
170
  .article__byline strong { color: var(--ink); font-weight: 500; }
171
171
  .article__avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--line); display: inline-block; flex: none; }
172
- .article__image { width: 100%; aspect-ratio: 21 / 9; object-fit: cover; border-radius: var(--radius); }
172
+ .article__image { width: 100%; border-radius: var(--radius); }
173
173
  .article__body { max-width: 720px; margin: 16px auto 0; width: 100%; }
174
174
 
175
175
  .related { border-top: 1px solid var(--line); margin-top: 88px; padding: 64px 0 96px; }
@@ -1,3 +1,4 @@
1
+ import { SmoodlyImage } from "smoodly/image";
1
2
  import { formatDate, type ArticleFields } from "./articles";
2
3
 
3
4
  // One article card: photo, first tag and date, title, excerpt. The grid
@@ -10,7 +11,7 @@ export function ArticleCard({ article, url, locale, compact = false }: {
10
11
  const Title = compact ? "h3" : "h2";
11
12
  return (
12
13
  <a className="card" href={url}>
13
- {article.image?.url && <img className="card__image" src={article.image.url} alt={article.image.alt ?? ""} />}
14
+ {article.image && <SmoodlyImage image={article.image} className="card__image" sizes="(min-width: 900px) 33vw, 100vw" />}
14
15
  <div className="card__meta">
15
16
  {article.tags?.[0] && <span className="card__tag">{article.tags[0]}</span>}
16
17
  {article.date && <span>{formatDate(article.date, locale)}</span>}
@@ -1,6 +1,7 @@
1
1
  // Small helpers shared by the Articles index, the article page and the
2
2
  // Favourite articles section — the article's URL, dates in the request
3
3
  // locale, and the read time from the richtext body.
4
+ import type { ResolvedImage } from "smoodly";
4
5
  import { articles } from "@/smoodly/collections";
5
6
 
6
7
  export type ArticleFields = {
@@ -9,7 +10,7 @@ export type ArticleFields = {
9
10
  excerpt?: string;
10
11
  body?: unknown;
11
12
  tags?: string[];
12
- image?: { url: string; alt?: string };
13
+ image?: ResolvedImage;
13
14
  date: string;
14
15
  };
15
16
 
@@ -1,4 +1,5 @@
1
1
  import { RichText, smoodly, type EntryPageProps } from "smoodly";
2
+ import { SmoodlyImage } from "smoodly/image";
2
3
  import { articles } from "@/smoodly/collections";
3
4
  import { site } from "@/smoodly/server";
4
5
  import { ArticleCard } from "../ArticleCard";
@@ -37,7 +38,7 @@ async function ArticleView({ entry }: EntryPageProps) {
37
38
  </span>
38
39
  </p>
39
40
  </div>
40
- {a.image?.url && <img className="article__image" src={a.image.url} alt={a.image.alt ?? ""} />}
41
+ {a.image && <SmoodlyImage image={a.image} aspect="21:9" className="article__image" sizes="(min-width: 900px) 800px, 100vw" priority />}
41
42
  <div className="article__body prose prose--lead"><RichText doc={a.body} /></div>
42
43
  </article>
43
44
  {related.length > 0 && (
@@ -1,4 +1,5 @@
1
1
  import { f, smoodly, type Props } from "smoodly";
2
+ import { SmoodlyImage } from "smoodly/image";
2
3
 
3
4
  // The home page opener: kicker, heading, text, up to two buttons and a
4
5
  // portrait photo. Home locks it (`z.locked(Hero)`): one node an editor
@@ -13,14 +14,13 @@ export const heroSchema = smoodly.schema.section({
13
14
  heading: f.text(),
14
15
  text: f.text(),
15
16
  buttons: f.list(f.object({ label: f.text(), href: f.link() })).max(2),
16
- image: f.image(),
17
+ image: f.image({ aspect: "4:5" }),
17
18
  },
18
19
  sample: {
19
20
  kicker: "Lorem ipsum dolor",
20
21
  heading: "Consectetur adipiscing elit sed do eiusmod",
21
22
  text: "Tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.",
22
23
  buttons: [{ label: "About us", href: "/company/about-us" }],
23
- image: { url: "/images/hero_image.jpg", alt: "Office interior" },
24
24
  },
25
25
  });
26
26
 
@@ -40,7 +40,7 @@ function HeroView({ kicker, heading, text, buttons, image, styles }: Props<typeo
40
40
  </div>
41
41
  )}
42
42
  </div>
43
- {image?.url && <img className="hero__image" src={image.url} alt={image.alt ?? ""} />}
43
+ {image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority />}
44
44
  </div>
45
45
  </smoodly.Section>
46
46
  );
@@ -1,4 +1,5 @@
1
1
  import { f, RichText, smoodly, type Props } from "smoodly";
2
+ import { SmoodlyImage } from "smoodly/image";
2
3
 
3
4
  // A photo beside a heading and richtext, the photo on either side (a
4
5
  // style, not a field: styles are never translated). Without a photo the
@@ -10,7 +11,7 @@ export const imageTextSchema = smoodly.schema.section({
10
11
  title: "Image and text",
11
12
  description: "A photo beside a heading and text; text alone in two columns without one.",
12
13
  fields: {
13
- image: f.image().optional(),
14
+ image: f.image({ aspect: "4:3" }).optional(),
14
15
  heading: f.text(),
15
16
  // Body copy beside a photo: emphasis and links, no headings — the block's
16
17
  // own heading field owns that. Every other richtext field takes the default.
@@ -39,9 +40,9 @@ function ImageTextView({ image, heading, text, link, styles }: Props<typeof imag
39
40
  return (
40
41
  <smoodly.Section styles={styles} className="image-text">
41
42
  <div className="container image-text__grid">
42
- {image?.url ? (
43
+ {image ? (
43
44
  <>
44
- <img className="image-text__image" src={image.url} alt={image.alt ?? ""} />
45
+ <SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" />
45
46
  <div className="image-text__copy">
46
47
  <h2 className="h2">{heading}</h2>
47
48
  {body}
@@ -1,4 +1,5 @@
1
1
  import { f, smoodly, type Props } from "smoodly";
2
+ import { SmoodlyImage } from "smoodly/image";
2
3
 
3
4
  // The opener of every page that is not the home page: kicker, heading,
4
5
  // text and an optional wide photo. Page and Articles lock it — each page
@@ -12,7 +13,7 @@ export const pageHeroSchema = smoodly.schema.section({
12
13
  kicker: f.text().optional(),
13
14
  heading: f.text(),
14
15
  text: f.text(),
15
- image: f.image().optional(),
16
+ image: f.image({ aspect: "21:9" }).optional(),
16
17
  },
17
18
  sample: {
18
19
  heading: "About us",
@@ -29,7 +30,7 @@ function PageHeroView({ kicker, heading, text, image, styles }: Props<typeof pag
29
30
  <h1 className="page-hero__heading">{heading}</h1>
30
31
  <p className="page-hero__text">{text}</p>
31
32
  </div>
32
- {image?.url && <img className="page-hero__image" src={image.url} alt={image.alt ?? ""} />}
33
+ {image && <SmoodlyImage image={image} className="page-hero__image" sizes="100vw" priority />}
33
34
  </div>
34
35
  </smoodly.Section>
35
36
  );
@@ -1,8 +1,11 @@
1
1
  import type { NextConfig } from "next";
2
2
  import createNextIntlPlugin from "next-intl/plugin";
3
+ import { smoodlyImages } from "smoodly/image/config";
3
4
 
4
5
  const withNextIntl = createNextIntlPlugin();
5
6
 
6
- const config: NextConfig = {};
7
+ // next/image may only optimize from the Supabase host named in SMOODLY_URL
8
+ // (a local stack also needs Next's local-IP allowance; the helper sets it).
9
+ const config: NextConfig = { images: smoodlyImages() };
7
10
 
8
11
  export default withNextIntl(config);
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.10",
17
+ "smoodly": "0.0.12",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {
@@ -14,8 +14,9 @@
14
14
  // both; what a reader perceives as language (nav, headings, tags,
15
15
  // roles, footer titles) is translated. Idempotent: exits once the home
16
16
  // page exists.
17
+ import { readFile } from "node:fs/promises";
17
18
  import next from "@next/env";
18
- import type { PageTree } from "smoodly";
19
+ import { imageDimensions, type PageTree } from "smoodly";
19
20
  const { loadEnvConfig } = next;
20
21
 
21
22
  loadEnvConfig(process.cwd());
@@ -23,7 +24,7 @@ loadEnvConfig(process.cwd());
23
24
  const { config } = await import("../smoodly/config");
24
25
  const { articles } = await import("../smoodly/collections");
25
26
  const { href, stores } = await import("../smoodly/server");
26
- const { pages, entries } = stores();
27
+ const { pages, entries, assets } = stores();
27
28
 
28
29
  const EN = config.locales.default;
29
30
  const FI = config.locales.supported.includes("fi") && EN !== "fi" ? "fi" : null;
@@ -46,7 +47,26 @@ const quoteBlock = (s: string): Node => ({ type: "blockquote", content: [p(s)] }
46
47
  const bullets = (...items: string[]): Node => ({ type: "bulletList", content: items.map((s) => ({ type: "listItem", content: [p(s)] })) });
47
48
  const doc = (...content: Node[]) => ({ type: "doc", content });
48
49
 
49
- const image = (name: string, alt: string) => ({ url: `/images/${name}.jpg`, alt });
50
+ // The starter's photos become assets: each file is uploaded once through
51
+ // the store (rows in `assets`, bytes in the `media` bucket), and a field
52
+ // value is `{ asset: <id> }` — the alt lives on the asset (spec 2026-09-08).
53
+ const uploaded = new Map<string, string>();
54
+ async function image(name: string, alt: string): Promise<{ asset: string }> {
55
+ let id = uploaded.get(name);
56
+ if (!id) {
57
+ const bytes = new Uint8Array(await readFile(`public/images/${name}.jpg`));
58
+ const dims = imageDimensions(bytes) ?? {};
59
+ id = (await assets.create({ filename: `${name}.jpg`, mime: "image/jpeg", size: bytes.byteLength, bytes, alt, title: alt, ...dims })).id;
60
+ uploaded.set(name, id);
61
+ }
62
+ return { asset: id };
63
+ }
64
+ const img = {
65
+ article: await image("article_photo", "Notebook on a desk"),
66
+ home2: await image("home_section_2", "Team working"),
67
+ hero: await image("hero_image", "Office interior"),
68
+ about: await image("about_main", "Team meeting"),
69
+ };
50
70
 
51
71
  /** The articles collection's URL segment in a locale, from its own `path`. */
52
72
  const articlesSegment = (locale: string) => {
@@ -136,7 +156,7 @@ const article = await seedEntry("articles", {
136
156
  excerpt: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
137
157
  body,
138
158
  tags: ["Insights", "Design"],
139
- image: image("article_photo", "Notebook on a desk"),
159
+ image: img.article,
140
160
  date: "2026-09-02",
141
161
  author: jane.id,
142
162
  }, {
@@ -190,7 +210,7 @@ const imageText = (locale: string, linkLabel: string): Node => ({
190
210
  id: "node-intro",
191
211
  type: "imageText",
192
212
  fields: {
193
- image: image("home_section_2", "Team working"),
213
+ image: img.home2,
194
214
  heading: "Duis aute irure dolor in reprehenderit",
195
215
  text: doc(
196
216
  p("In voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."),
@@ -236,7 +256,7 @@ const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]
236
256
  { label: t.buttons[0], href: paths(locale).about },
237
257
  { label: t.buttons[1], href: paths(locale).articles },
238
258
  ],
239
- image: image("hero_image", "Office interior"),
259
+ image: img.hero,
240
260
  },
241
261
  }],
242
262
  body: [imageText(locale, t.link), quote, points, favourites(t.favourites)],
@@ -279,7 +299,7 @@ const aboutTree = (t: { kicker: string; people: string }): Tree => ({
279
299
  kicker: t.kicker,
280
300
  heading: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
281
301
  text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
282
- image: image("about_main", "Team meeting"),
302
+ image: img.about,
283
303
  },
284
304
  }],
285
305
  body: [
@@ -33,7 +33,7 @@ export const articles = smoodly.collection({
33
33
  excerpt: f.text().localized(),
34
34
  body: f.richtext().localized(),
35
35
  tags: f.list(f.text()).localized(),
36
- image: f.image(),
36
+ image: f.image({ aspect: "3:2" }),
37
37
  date: f.date(),
38
38
  author: f.ref(() => people),
39
39
  },
@@ -9,7 +9,7 @@
9
9
  // admin route enters it from config.ts — so nothing here may read the
10
10
  // config at module scope.
11
11
  import {
12
- createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
12
+ createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
13
13
  } from "smoodly";
14
14
  import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
15
15
  import { createPageRenderer, type PageRegistration } from "smoodly/next";
@@ -19,7 +19,7 @@ import { collections, config, sections, shared } from "./config";
19
19
 
20
20
  type Renderer = ReturnType<typeof createPageRenderer>;
21
21
  type Bound = {
22
- db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer;
22
+ db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer;
23
23
  };
24
24
 
25
25
  let cached: Bound | null = null;
@@ -37,10 +37,12 @@ function bound(): Bound {
37
37
  homePageSlug: config.homePageSlug,
38
38
  tree: config.pages?.tree,
39
39
  });
40
+ // The media library: rows in `assets`, bytes in the public `media` bucket.
41
+ const assets = new SupabaseAssetStore(db);
40
42
  const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
41
43
  // Shadows the exported `stores` below on purpose: `createSmoodlySite`
42
44
  // takes this local closure, not the export, so it never re-enters `bound()`.
43
- const stores = () => ({ pages, entries });
45
+ const stores = () => ({ pages, entries, assets });
44
46
  // The site layer: (locale, path) → page or entry, and the content API
45
47
  // (site.getPagesTree for menus, site.getEntries for lists).
46
48
  // Shadows the exported `site` below on purpose: `createPageRenderer`
@@ -52,13 +54,13 @@ function bound(): Bound {
52
54
  pages: (config.registry.pages ?? []) as PageRegistration[],
53
55
  href,
54
56
  });
55
- cached = { db, pages, entries, auth, site, renderer };
57
+ cached = { db, pages, entries, assets, auth, site, renderer };
56
58
  return cached;
57
59
  }
58
60
 
59
61
  export function stores() {
60
- const { pages, entries } = bound();
61
- return { pages, entries };
62
+ const { pages, entries, assets } = bound();
63
+ return { pages, entries, assets };
62
64
  }
63
65
 
64
66
  /** The server-side client (seed script, one-off admin tasks). */
@@ -164,6 +164,35 @@ create table if not exists "paths" (
164
164
  create index if not exists "paths_target_idx"
165
165
  on "paths" ("space_id", "kind", "target_id");
166
166
 
167
+ -- Media (spec 2026-09-08): one row per upload, bytes in the "media"
168
+ -- bucket at "path". Immediate — never draft-gated, never versioned —
169
+ -- so there is no locale row and no version table. status 'pending' is
170
+ -- a row whose signed upload has not been confirmed; list never shows it.
171
+ create table if not exists "assets" (
172
+ "id" uuid primary key default gen_random_uuid(),
173
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
174
+ "kind" text not null check ("kind" in ('image', 'video', 'file')),
175
+ "status" text not null default 'pending' check ("status" in ('pending', 'ready')),
176
+ "path" text not null,
177
+ "mime" text not null,
178
+ "size" bigint not null,
179
+ "width" integer,
180
+ "height" integer,
181
+ "duration" numeric,
182
+ "poster_id" uuid references "assets" ("id") on delete set null,
183
+ "filename" text not null,
184
+ "title" text,
185
+ "alt" text,
186
+ "caption" text,
187
+ "credit" text,
188
+ "created_by" text,
189
+ "created_at" timestamptz not null default now(),
190
+ "updated_at" timestamptz not null default now()
191
+ );
192
+
193
+ create index if not exists "assets_space_created_idx"
194
+ on "assets" ("space_id", "status", "created_at" desc);
195
+
167
196
  -- Replace the targets' rows with the given rows in ONE call: a SQL
168
197
  -- function runs in the caller's transaction, so a primary-key collision
169
198
  -- rolls the whole rewrite back. Inserted rows get their space from the
@@ -193,6 +222,7 @@ alter table "entry_locales" enable row level security;
193
222
  alter table "entry_versions" enable row level security;
194
223
  alter table "refs" enable row level security;
195
224
  alter table "paths" enable row level security;
225
+ alter table "assets" enable row level security;
196
226
 
197
227
  -- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
198
228
  -- for everyone with ONE definition. Self-host fills them by hand or
@@ -329,6 +359,10 @@ drop trigger if exists "smoodly_a_set_space" on "paths";
329
359
  create trigger "smoodly_a_set_space" before insert on "paths"
330
360
  for each row execute function "smoodly_set_space"();
331
361
 
362
+ drop trigger if exists "smoodly_a_set_space" on "assets";
363
+ create trigger "smoodly_a_set_space" before insert on "assets"
364
+ for each row execute function "smoodly_set_space"();
365
+
332
366
  drop trigger if exists "smoodly_a_set_space" on "editors";
333
367
  create trigger "smoodly_a_set_space" before insert on "editors"
334
368
  for each row execute function "smoodly_set_space"();
@@ -365,6 +399,10 @@ drop trigger if exists "smoodly_b_row_cap" on "paths";
365
399
  create trigger "smoodly_b_row_cap" before insert on "paths"
366
400
  for each row execute function "smoodly_enforce_row_cap"();
367
401
 
402
+ drop trigger if exists "smoodly_b_row_cap" on "assets";
403
+ create trigger "smoodly_b_row_cap" before insert on "assets"
404
+ for each row execute function "smoodly_enforce_row_cap"();
405
+
368
406
  drop policy if exists "smoodly_write" on "pages";
369
407
  create policy "smoodly_write" on "pages" for all
370
408
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
@@ -433,11 +471,33 @@ drop policy if exists "smoodly_read" on "paths";
433
471
  create policy "smoodly_read" on "paths" for select
434
472
  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'))));
435
473
 
474
+ drop policy if exists "smoodly_write" on "assets";
475
+ create policy "smoodly_write" on "assets" for all
476
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
477
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
478
+
479
+ drop policy if exists "smoodly_read" on "assets";
480
+ create policy "smoodly_read" on "assets" for select
481
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'ready');
482
+
436
483
  drop policy if exists "smoodly_write" on "editors";
437
484
  create policy "smoodly_write" on "editors" for all
438
485
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
439
486
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
440
487
 
488
+ insert into storage.buckets ("id", "name", "public", "file_size_limit")
489
+ values ('media', 'media', true, 104857600)
490
+ on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
491
+
492
+ drop policy if exists "smoodly_media_read" on storage.objects;
493
+ create policy "smoodly_media_read" on storage.objects for select
494
+ using ("bucket_id" = 'media');
495
+
496
+ drop policy if exists "smoodly_media_write" on storage.objects;
497
+ create policy "smoodly_media_write" on storage.objects for all
498
+ using ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text)
499
+ with check ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text);
500
+
441
501
  create or replace view "people" with (security_invoker = true) as
442
502
  select
443
503
  e."id",