create-smoodly-app 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/next/app/globals.css +5 -5
- package/templates/next/components/ArticleCard.tsx +2 -1
- package/templates/next/components/articles.ts +2 -1
- package/templates/next/components/pages/Article.tsx +2 -1
- package/templates/next/components/sections/Hero.tsx +3 -3
- package/templates/next/components/sections/ImageText.tsx +7 -4
- package/templates/next/components/sections/PageHero.tsx +3 -2
- package/templates/next/next.config.ts +4 -1
- package/templates/next/package.json +1 -1
- package/templates/next/scripts/seed.ts +27 -7
- package/templates/next/smoodly/collections.ts +1 -1
- package/templates/next/smoodly/server.ts +8 -6
- package/templates/next/supabase/migrations/00000000000000_smoodly.sql +60 -0
- package/templates/next-intl/app/globals.css +5 -5
- package/templates/next-intl/components/ArticleCard.tsx +2 -1
- package/templates/next-intl/components/articles.ts +2 -1
- package/templates/next-intl/components/pages/Article.tsx +2 -1
- package/templates/next-intl/components/sections/Hero.tsx +3 -3
- package/templates/next-intl/components/sections/ImageText.tsx +7 -4
- package/templates/next-intl/components/sections/PageHero.tsx +3 -2
- package/templates/next-intl/next.config.ts +4 -1
- package/templates/next-intl/package.json +1 -1
- package/templates/next-intl/scripts/seed.ts +27 -7
- package/templates/next-intl/smoodly/collections.ts +1 -1
- package/templates/next-intl/smoodly/server.ts +8 -6
- 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 —
|
|
19
|
-
|
|
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
|
|
@@ -25,7 +25,10 @@ sign in. Spec: `docs/superpowers/specs/2026-09-04-create-smoodly-app-design.md`.
|
|
|
25
25
|
from the URL so the CLI never opens its project picker). The URL may be
|
|
26
26
|
pasted without `https://`; anything that is not an http(s) URL is refused
|
|
27
27
|
before any step runs. A custom domain has no ref, so the link is not
|
|
28
|
-
offered and the two commands are printed instead.
|
|
28
|
+
offered and the two commands are printed instead. Either way the
|
|
29
|
+
project is Smoodly's own: its tables are created unprefixed in `public`,
|
|
30
|
+
so an app that keeps its own tables in Supabase gives them a project of
|
|
31
|
+
their own, or takes Smoodly Cloud for content (DESIGN.md §1).
|
|
29
32
|
4. The first editor: pick an existing Auth user or create one (email +
|
|
30
33
|
password). The `editors` row is written with role `owner`.
|
|
31
34
|
If `@` does not appear when you type it (macOS, `@` on Option+2 with the
|
|
@@ -83,6 +86,9 @@ multilingual one with `--locales en,de`.
|
|
|
83
86
|
app's stack with `-x storage-api,imgproxy,edge-runtime,logflare,vector,realtime,supavisor`
|
|
84
87
|
(`LOCAL_START_ARGS`), and the "Finish by hand" text carries the same
|
|
85
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.
|
|
86
92
|
- **A non-interactive run without `--locales` needs `--yes`.** The
|
|
87
93
|
"Multilingual site?" prompt is asked whenever `--locales` is absent, and
|
|
88
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", "
|
|
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
|
@@ -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%;
|
|
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%;
|
|
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%;
|
|
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%;
|
|
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%;
|
|
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
|
|
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?:
|
|
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
|
|
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
|
|
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,9 +11,11 @@ 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
|
|
17
|
+
// own heading field owns that. Every other richtext field takes the default.
|
|
18
|
+
text: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
|
|
16
19
|
link: f.object({ label: f.text(), href: f.link() }).optional(),
|
|
17
20
|
},
|
|
18
21
|
styles: {
|
|
@@ -37,9 +40,9 @@ function ImageTextView({ image, heading, text, link, styles }: Props<typeof imag
|
|
|
37
40
|
return (
|
|
38
41
|
<smoodly.Section styles={styles} className="image-text">
|
|
39
42
|
<div className="container image-text__grid">
|
|
40
|
-
{image
|
|
43
|
+
{image ? (
|
|
41
44
|
<>
|
|
42
|
-
<
|
|
45
|
+
<SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" />
|
|
43
46
|
<div className="image-text__copy">
|
|
44
47
|
<h2 className="h2">{heading}</h2>
|
|
45
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
|
|
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";
|
|
2
3
|
|
|
3
|
-
|
|
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,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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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%;
|
|
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%;
|
|
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%;
|
|
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%;
|
|
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%;
|
|
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
|
|
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?:
|
|
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
|
|
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
|
|
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,9 +11,11 @@ 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
|
|
17
|
+
// own heading field owns that. Every other richtext field takes the default.
|
|
18
|
+
text: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
|
|
16
19
|
link: f.object({ label: f.text(), href: f.link() }).optional(),
|
|
17
20
|
},
|
|
18
21
|
styles: {
|
|
@@ -37,9 +40,9 @@ function ImageTextView({ image, heading, text, link, styles }: Props<typeof imag
|
|
|
37
40
|
return (
|
|
38
41
|
<smoodly.Section styles={styles} className="image-text">
|
|
39
42
|
<div className="container image-text__grid">
|
|
40
|
-
{image
|
|
43
|
+
{image ? (
|
|
41
44
|
<>
|
|
42
|
-
<
|
|
45
|
+
<SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" />
|
|
43
46
|
<div className="image-text__copy">
|
|
44
47
|
<h2 className="h2">{heading}</h2>
|
|
45
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
|
|
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";
|
|
3
4
|
|
|
4
5
|
const withNextIntl = createNextIntlPlugin();
|
|
5
6
|
|
|
6
|
-
|
|
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,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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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",
|