create-smoodly-app 0.0.7 → 0.0.9
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/dist/index.js +22 -2
- package/package.json +1 -1
- package/templates/next/README.md +18 -2
- package/templates/next/app/globals.css +190 -0
- package/templates/next/app/layout.tsx +28 -4
- package/templates/next/components/ArticleCard.tsx +22 -0
- package/templates/next/components/articles.ts +38 -0
- package/templates/next/components/pages/Article.tsx +58 -10
- package/templates/next/components/pages/Articles.tsx +44 -0
- package/templates/next/components/pages/Home.tsx +12 -10
- package/templates/next/components/pages/Page.tsx +11 -8
- package/templates/next/components/sections/FavouriteArticles.tsx +42 -0
- package/templates/next/components/sections/Footer.tsx +69 -0
- package/templates/next/components/sections/Header.tsx +44 -0
- package/templates/next/components/sections/Hero.tsx +32 -7
- package/templates/next/components/sections/ImageText.tsx +59 -0
- package/templates/next/components/sections/PageHero.tsx +38 -0
- package/templates/next/components/sections/People.tsx +36 -0
- package/templates/next/components/sections/Points.tsx +50 -0
- package/templates/next/components/sections/Quote.tsx +33 -0
- package/templates/next/components/strings.ts +23 -0
- package/templates/next/messages/en.json +5 -0
- package/templates/next/package.json +4 -1
- package/templates/next/public/images/about_main.jpg +0 -0
- package/templates/next/public/images/article_photo.jpg +0 -0
- package/templates/next/public/images/hero_image.jpg +0 -0
- package/templates/next/public/images/home_section_2.jpg +0 -0
- package/templates/next/scripts/seed.ts +310 -0
- package/templates/next/smoodly/collections.ts +12 -3
- package/templates/next/smoodly/config.ts +12 -3
- package/templates/next/smoodly/sections.ts +14 -3
- package/templates/next/smoodly/server.ts +39 -21
- package/templates/next/smoodly/shared.ts +26 -0
- package/templates/next/supabase/migrations/00000000000000_smoodly.sql +7 -52
- package/templates/next-intl/README.md +19 -3
- package/templates/next-intl/app/[locale]/layout.tsx +27 -4
- package/templates/next-intl/app/globals.css +190 -0
- package/templates/next-intl/components/ArticleCard.tsx +22 -0
- package/templates/next-intl/components/articles.ts +38 -0
- package/templates/next-intl/components/pages/Article.tsx +58 -10
- package/templates/next-intl/components/pages/Articles.tsx +44 -0
- package/templates/next-intl/components/pages/Home.tsx +12 -10
- package/templates/next-intl/components/pages/Page.tsx +11 -8
- package/templates/next-intl/components/sections/FavouriteArticles.tsx +42 -0
- package/templates/next-intl/components/sections/Footer.tsx +69 -0
- package/templates/next-intl/components/sections/Header.tsx +44 -0
- package/templates/next-intl/components/sections/Hero.tsx +32 -7
- package/templates/next-intl/components/sections/ImageText.tsx +59 -0
- package/templates/next-intl/components/sections/PageHero.tsx +38 -0
- package/templates/next-intl/components/sections/People.tsx +36 -0
- package/templates/next-intl/components/sections/Points.tsx +50 -0
- package/templates/next-intl/components/sections/Quote.tsx +33 -0
- package/templates/next-intl/components/strings.ts +23 -0
- package/templates/next-intl/i18n/request.ts +14 -7
- package/templates/next-intl/messages/en.json +5 -0
- package/templates/next-intl/messages/fi.json +5 -0
- package/templates/next-intl/package.json +4 -1
- package/templates/next-intl/public/images/about_main.jpg +0 -0
- package/templates/next-intl/public/images/article_photo.jpg +0 -0
- package/templates/next-intl/public/images/hero_image.jpg +0 -0
- package/templates/next-intl/public/images/home_section_2.jpg +0 -0
- package/templates/next-intl/scripts/seed.ts +310 -0
- package/templates/next-intl/smoodly/collections.ts +13 -4
- package/templates/next-intl/smoodly/config.ts +12 -3
- package/templates/next-intl/smoodly/sections.ts +14 -3
- package/templates/next-intl/smoodly/server.ts +39 -21
- package/templates/next-intl/smoodly/shared.ts +26 -0
- package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +7 -52
- package/templates/next/components/sections/ArticleList.tsx +0 -31
- package/templates/next/components/sections/CtaBanner.tsx +0 -35
- package/templates/next-intl/components/sections/ArticleList.tsx +0 -31
- package/templates/next-intl/components/sections/CtaBanner.tsx +0 -35
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// The starter's content, through the stores (never raw inserts — the
|
|
2
|
+
// refs index and publish integrity must be exercised, that's the point).
|
|
3
|
+
// Content only; it ships in the template and create-smoodly-app runs
|
|
4
|
+
// `npm run seed` after the first editor. (The example repo's demo
|
|
5
|
+
// editor account is a separate, example-only script.)
|
|
6
|
+
// Run: npm run seed (needs .env.local and a Supabase stack).
|
|
7
|
+
//
|
|
8
|
+
// Locales: the English content goes into the configured default locale,
|
|
9
|
+
// whatever its code, and the Finnish rows are written only when `fi` is
|
|
10
|
+
// supported and not the default (spec 2026-09-07 starter §4). The
|
|
11
|
+
// installer accepts any locale set; URL segments come from the
|
|
12
|
+
// registrations (the articles collection's per-locale `path`), so a
|
|
13
|
+
// `fi` default lands the index on `artikkelit`. Body copy is Latin in
|
|
14
|
+
// both; what a reader perceives as language (nav, headings, tags,
|
|
15
|
+
// roles, footer titles) is translated. Idempotent: exits once the home
|
|
16
|
+
// page exists.
|
|
17
|
+
import next from "@next/env";
|
|
18
|
+
import type { PageTree } from "smoodly";
|
|
19
|
+
const { loadEnvConfig } = next;
|
|
20
|
+
|
|
21
|
+
loadEnvConfig(process.cwd());
|
|
22
|
+
|
|
23
|
+
const { config } = await import("../smoodly/config");
|
|
24
|
+
const { articles } = await import("../smoodly/collections");
|
|
25
|
+
const { href, stores } = await import("../smoodly/server");
|
|
26
|
+
const { pages, entries } = stores();
|
|
27
|
+
|
|
28
|
+
const EN = config.locales.default;
|
|
29
|
+
const FI = config.locales.supported.includes("fi") && EN !== "fi" ? "fi" : null;
|
|
30
|
+
|
|
31
|
+
if (await pages.resolvePath(EN, "/")) {
|
|
32
|
+
console.log(`Already seeded — "home" exists in "${EN}".`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- helpers -------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
type Fields = Record<string, unknown>;
|
|
39
|
+
type Node = Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
// Richtext is TipTap JSON, the stored format; RichText renders it.
|
|
42
|
+
const text = (s: string) => [{ type: "text", text: s }];
|
|
43
|
+
const p = (s: string): Node => ({ type: "paragraph", content: text(s) });
|
|
44
|
+
const h2 = (s: string): Node => ({ type: "heading", attrs: { level: 2 }, content: text(s) });
|
|
45
|
+
const quoteBlock = (s: string): Node => ({ type: "blockquote", content: [p(s)] });
|
|
46
|
+
const bullets = (...items: string[]): Node => ({ type: "bulletList", content: items.map((s) => ({ type: "listItem", content: [p(s)] })) });
|
|
47
|
+
const doc = (...content: Node[]) => ({ type: "doc", content });
|
|
48
|
+
|
|
49
|
+
const image = (name: string, alt: string) => ({ url: `/images/${name}.jpg`, alt });
|
|
50
|
+
|
|
51
|
+
/** The articles collection's URL segment in a locale, from its own `path`. */
|
|
52
|
+
const articlesSegment = (locale: string) => {
|
|
53
|
+
const declared = articles.path;
|
|
54
|
+
return typeof declared === "string" ? declared : (declared?.[locale] ?? declared?.[EN] ?? "posts");
|
|
55
|
+
};
|
|
56
|
+
/** Site URLs for the starter's own pages in a locale. */
|
|
57
|
+
const paths = (locale: string) => ({
|
|
58
|
+
home: href("/", locale),
|
|
59
|
+
articles: href(`/${articlesSegment(locale)}`, locale),
|
|
60
|
+
about: href(locale === FI ? "/yritys/tietoa-meista" : "/company/about-us", locale),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** Create an entry (a collection's or a shared item's) in EN, add the FI
|
|
64
|
+
* row when there is one, and publish both — every entry is published in
|
|
65
|
+
* every locale it has BEFORE any page, because publish integrity is per
|
|
66
|
+
* locale and would (rightly) block a page whose tree references an entry
|
|
67
|
+
* that is a draft, or absent, in that locale. `fi` holds the localized
|
|
68
|
+
* fields; shared ones are repeated from `en`. */
|
|
69
|
+
async function seedEntry(collection: string, en: Fields, fi?: Fields) {
|
|
70
|
+
const row = await entries.create(collection, en, { locale: EN });
|
|
71
|
+
await entries.setStatus(collection, row.id, EN, "published");
|
|
72
|
+
if (FI && fi) {
|
|
73
|
+
await entries.addLocale(collection, row.id, FI, { from: EN });
|
|
74
|
+
await entries.update(collection, row.id, FI, { ...en, ...fi });
|
|
75
|
+
await entries.setStatus(collection, row.id, FI, "published");
|
|
76
|
+
}
|
|
77
|
+
return row;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type Tree = { zones: Record<string, Node[]> };
|
|
81
|
+
|
|
82
|
+
/** Create a page (or a folder: `template: null`) in EN with its tree,
|
|
83
|
+
* publish it, then the FI row with its own tree — a locale's tree is
|
|
84
|
+
* its own (spec 2026-09-06); the Finnish page is copied from the
|
|
85
|
+
* English draft, then overwritten with the Finnish tree. */
|
|
86
|
+
async function seedPage(
|
|
87
|
+
input: { slug: string; title: string; template: string | null; parentId?: string; fi?: { slug?: string; title: string } },
|
|
88
|
+
trees?: { en: Tree; fi?: Tree },
|
|
89
|
+
) {
|
|
90
|
+
const page = await pages.createPage({
|
|
91
|
+
locale: EN, slug: input.slug, title: input.title, template: input.template,
|
|
92
|
+
...(input.parentId ? { parentId: input.parentId } : {}),
|
|
93
|
+
});
|
|
94
|
+
if (trees) {
|
|
95
|
+
await pages.saveDraft(page.id, EN, trees.en as unknown as PageTree);
|
|
96
|
+
await pages.publish(page.id, EN);
|
|
97
|
+
}
|
|
98
|
+
if (FI && input.fi) {
|
|
99
|
+
await pages.addLocale(page.id, FI, { from: EN, title: input.fi.title, ...(input.fi.slug ? { slug: input.fi.slug } : {}) });
|
|
100
|
+
if (trees?.fi) {
|
|
101
|
+
await pages.saveDraft(page.id, FI, trees.fi as unknown as PageTree);
|
|
102
|
+
await pages.publish(page.id, FI);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return page;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- people ---------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const jane = await seedEntry("people", { name: "Jane Doe", role: "Founder" }, { role: "Perustaja" });
|
|
111
|
+
const john = await seedEntry("people", { name: "John Smith", role: "Creative Director" }, { role: "Luova johtaja" });
|
|
112
|
+
const anna = await seedEntry("people", { name: "Anna Lorem", role: "Head of Design" }, { role: "Muotoilujohtaja" });
|
|
113
|
+
const mark = await seedEntry("people", { name: "Mark Ipsum", role: "Developer" }, { role: "Kehittäjä" });
|
|
114
|
+
|
|
115
|
+
// --- the article ----------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const body = doc(
|
|
118
|
+
p("Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."),
|
|
119
|
+
p("Duis aute irure dolor in reprehenderit 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."),
|
|
120
|
+
h2("Sed ut perspiciatis unde omnis"),
|
|
121
|
+
p("Iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo."),
|
|
122
|
+
quoteBlock("Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit."),
|
|
123
|
+
p("Sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit."),
|
|
124
|
+
h2("Quis autem vel eum iure"),
|
|
125
|
+
p("Reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur."),
|
|
126
|
+
bullets("At vero eos et accusamus et iusto odio", "Dignissimos ducimus qui blanditiis praesentium", "Voluptatum deleniti atque corrupti quos dolores"),
|
|
127
|
+
p("Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus."),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Versioned (`versions: true` on the collection): every save below is a
|
|
131
|
+
// snapshot. Title and body are Latin in both locales; tags and slug are
|
|
132
|
+
// per locale, so the Finnish row reads Finnish where a reader would notice.
|
|
133
|
+
const article = await seedEntry("articles", {
|
|
134
|
+
title: "Lorem ipsum dolor sit amet consectetur adipiscing",
|
|
135
|
+
slug: "lorem-ipsum-dolor-sit-amet",
|
|
136
|
+
excerpt: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
|
137
|
+
body,
|
|
138
|
+
tags: ["Insights", "Design"],
|
|
139
|
+
image: image("article_photo", "Notebook on a desk"),
|
|
140
|
+
date: "2026-09-02",
|
|
141
|
+
author: jane.id,
|
|
142
|
+
}, {
|
|
143
|
+
slug: "lorem-ipsum-dolor-sit-amet-fi",
|
|
144
|
+
tags: ["Näkemyksiä", "Muotoilu"],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// --- shared items ---------------------------------------------------
|
|
148
|
+
|
|
149
|
+
const nav = (locale: string, labels: [string, string, string]) => {
|
|
150
|
+
const links = paths(locale);
|
|
151
|
+
return [
|
|
152
|
+
{ label: labels[0], href: links.home },
|
|
153
|
+
{ label: labels[1], href: links.articles },
|
|
154
|
+
{ label: labels[2], href: links.about },
|
|
155
|
+
];
|
|
156
|
+
};
|
|
157
|
+
await seedEntry("header",
|
|
158
|
+
{ brand: "Lorem Co.", nav: nav(EN, ["Home", "Articles", "About"]) },
|
|
159
|
+
FI ? { nav: nav(FI, ["Etusivu", "Artikkelit", "Tietoa meistä"]) } : undefined,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
await seedEntry("footer", {
|
|
163
|
+
tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
|
164
|
+
company: { title: "Company", links: [{ label: "About", href: paths(EN).about }, { label: "Articles", href: paths(EN).articles }] },
|
|
165
|
+
legal: { title: "Legal", links: [{ label: "Privacy", href: "#" }, { label: "Terms", href: "#" }] },
|
|
166
|
+
social: { title: "Social", links: [{ label: "Instagram", href: "#" }, { label: "LinkedIn", href: "#" }] },
|
|
167
|
+
copyright: "© 2026 Lorem Co. Built with Smoodly.",
|
|
168
|
+
}, FI ? {
|
|
169
|
+
tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
|
170
|
+
company: { title: "Yritys", links: [{ label: "Tietoa meistä", href: paths(FI).about }, { label: "Artikkelit", href: paths(FI).articles }] },
|
|
171
|
+
legal: { title: "Lakiasiat", links: [{ label: "Tietosuoja", href: "#" }, { label: "Ehdot", href: "#" }] },
|
|
172
|
+
social: { title: "Some", links: [{ label: "Instagram", href: "#" }, { label: "LinkedIn", href: "#" }] },
|
|
173
|
+
} : undefined);
|
|
174
|
+
|
|
175
|
+
await seedEntry("contact",
|
|
176
|
+
{ email: "hello@lorem.co", phone: "+358 40 123 4567", address: "Lorem Street 1, 00100 Helsinki" },
|
|
177
|
+
{ address: "Loremkatu 1, 00100 Helsinki" },
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const quoteRow = await seedEntry("quote",
|
|
181
|
+
{ text: "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores.", attribution: "Jane Doe, Founder" },
|
|
182
|
+
{ attribution: "Jane Doe, perustaja" },
|
|
183
|
+
);
|
|
184
|
+
// A placement: the quote as a linked node, the same row wherever it sits.
|
|
185
|
+
const quote: Node = { id: "node-quote", type: "shared", fields: { item: "quote", ref: quoteRow.id } };
|
|
186
|
+
|
|
187
|
+
// --- pages ----------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
const imageText = (locale: string, linkLabel: string): Node => ({
|
|
190
|
+
id: "node-intro",
|
|
191
|
+
type: "imageText",
|
|
192
|
+
fields: {
|
|
193
|
+
image: image("home_section_2", "Team working"),
|
|
194
|
+
heading: "Duis aute irure dolor in reprehenderit",
|
|
195
|
+
text: doc(
|
|
196
|
+
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."),
|
|
197
|
+
p("Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium."),
|
|
198
|
+
),
|
|
199
|
+
link: { label: linkLabel, href: paths(locale).about },
|
|
200
|
+
},
|
|
201
|
+
styles: { imageSide: "left" },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const points: Node = {
|
|
205
|
+
id: "node-points",
|
|
206
|
+
type: "points",
|
|
207
|
+
fields: {
|
|
208
|
+
heading: "Quis autem vel eum",
|
|
209
|
+
points: [
|
|
210
|
+
{ icon: "circle", heading: "Iure reprehenderit", text: "Qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat." },
|
|
211
|
+
{ icon: "square", heading: "Quo voluptas nulla", text: "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum." },
|
|
212
|
+
{ icon: "diamond", heading: "Pariatur deleniti", text: "Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga." },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
styles: { spacing: "lg" },
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const favourites = (heading: string): Node => ({
|
|
219
|
+
id: "node-favourites",
|
|
220
|
+
type: "favouriteArticles",
|
|
221
|
+
fields: { heading, items: [article.id] },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]; link: string; favourites: string }): Tree => ({
|
|
225
|
+
zones: {
|
|
226
|
+
// The locked hero's one node. Pages created in the admin get theirs
|
|
227
|
+
// from the editor (fillLockedZones on load); a seeded tree brings its own.
|
|
228
|
+
hero: [{
|
|
229
|
+
id: "node-hero",
|
|
230
|
+
type: "hero",
|
|
231
|
+
fields: {
|
|
232
|
+
kicker: t.kicker,
|
|
233
|
+
heading: "Consectetur adipiscing elit sed do eiusmod",
|
|
234
|
+
text: "Tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.",
|
|
235
|
+
buttons: [
|
|
236
|
+
{ label: t.buttons[0], href: paths(locale).about },
|
|
237
|
+
{ label: t.buttons[1], href: paths(locale).articles },
|
|
238
|
+
],
|
|
239
|
+
image: image("hero_image", "Office interior"),
|
|
240
|
+
},
|
|
241
|
+
}],
|
|
242
|
+
body: [imageText(locale, t.link), quote, points, favourites(t.favourites)],
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await seedPage(
|
|
247
|
+
{ slug: "home", title: "Home", template: "home", fi: { title: "Etusivu" } },
|
|
248
|
+
{
|
|
249
|
+
en: homeTree(EN, { kicker: "Lorem ipsum dolor", buttons: ["About us", "Read articles"], link: "About Lorem Co. →", favourites: "From the journal" }),
|
|
250
|
+
fi: FI ? homeTree(FI, { kicker: "Lorem ipsum dolor", buttons: ["Tietoa meistä", "Lue artikkelit"], link: "Tietoa Lorem Co:sta →", favourites: "Ajankohtaista" }) : undefined,
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// The Articles index: the fixed page at the collection's own segments,
|
|
255
|
+
// so the mount in the Pages list opens this page. Only a hero; the grid
|
|
256
|
+
// is the view's own fetch.
|
|
257
|
+
const articlesHero = (heading: string, text: string): Tree => ({
|
|
258
|
+
zones: { hero: [{ id: "node-articles-hero", type: "pageHero", fields: { heading, text } }] },
|
|
259
|
+
});
|
|
260
|
+
await seedPage(
|
|
261
|
+
{ slug: articlesSegment(EN), title: "Articles", template: "articlesIndex", fi: { slug: articlesSegment(FI ?? EN), title: "Artikkelit" } },
|
|
262
|
+
{
|
|
263
|
+
en: articlesHero("Articles", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
|
|
264
|
+
fi: articlesHero("Artikkelit", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// A folder and a page under it: the tree the admin lists, and the
|
|
269
|
+
// breadcrumb the Page shape renders from the `page` prop. Each exists in
|
|
270
|
+
// both locales with its own slug and title.
|
|
271
|
+
const company = await seedPage({ slug: "company", title: "Company", template: null, fi: { slug: "yritys", title: "Yritys" } });
|
|
272
|
+
|
|
273
|
+
const aboutTree = (t: { kicker: string; people: string }): Tree => ({
|
|
274
|
+
zones: {
|
|
275
|
+
hero: [{
|
|
276
|
+
id: "node-about-hero",
|
|
277
|
+
type: "pageHero",
|
|
278
|
+
fields: {
|
|
279
|
+
kicker: t.kicker,
|
|
280
|
+
heading: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
|
|
281
|
+
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"),
|
|
283
|
+
},
|
|
284
|
+
}],
|
|
285
|
+
body: [
|
|
286
|
+
{
|
|
287
|
+
id: "node-about-text",
|
|
288
|
+
type: "imageText",
|
|
289
|
+
fields: {
|
|
290
|
+
heading: "Duis aute irure dolor in reprehenderit",
|
|
291
|
+
text: doc(
|
|
292
|
+
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."),
|
|
293
|
+
p("Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis."),
|
|
294
|
+
p("Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt."),
|
|
295
|
+
),
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
quote,
|
|
299
|
+
{ id: "node-people", type: "peopleSection", fields: { heading: t.people, people: [jane.id, john.id, anna.id, mark.id] } },
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
await seedPage(
|
|
304
|
+
{ slug: "about-us", title: "About us", template: "page", parentId: company.id, fi: { slug: "tietoa-meista", title: "Tietoa meistä" } },
|
|
305
|
+
{ en: aboutTree({ kicker: "About us", people: "Our people" }), fi: aboutTree({ kicker: "Tietoa meistä", people: "Meidän väki" }) },
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
console.log(
|
|
309
|
+
`Seeded Lorem Co. in "${EN}"${FI ? ` and "${FI}"` : ""}: home, the Articles index (${articlesSegment(EN)}${FI ? ` / ${articlesSegment(FI)}` : ""}), company/about-us${FI ? " (yritys/tietoa-meista)" : ""}, one article, four people, header, footer, contact and quote — all published.`,
|
|
310
|
+
);
|
|
@@ -9,23 +9,32 @@ export const people = smoodly.collection({
|
|
|
9
9
|
titleField: "name",
|
|
10
10
|
fields: {
|
|
11
11
|
name: f.text(),
|
|
12
|
-
role: f.text().
|
|
12
|
+
role: f.text().localized(),
|
|
13
13
|
},
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
// Articles keep a version history per locale (`versions: true`): every
|
|
17
17
|
// save is a snapshot, publish moves a pointer, and the published site
|
|
18
|
-
// keeps serving the published snapshot while a draft moves on.
|
|
18
|
+
// keeps serving the published snapshot while a draft moves on. The path
|
|
19
|
+
// is per locale — /posts/<slug> and /fi/artikkelit/<slug> — and the
|
|
20
|
+
// Articles fixed page (components/pages/Articles.tsx) claims the same
|
|
21
|
+
// segments, which makes it the collection's index page. Tags are a list
|
|
22
|
+
// localized as a whole, so the Finnish row carries Finnish tags.
|
|
19
23
|
export const articles = smoodly.collection({
|
|
20
24
|
name: "articles",
|
|
21
25
|
title: "Articles",
|
|
22
26
|
titleField: "title",
|
|
23
27
|
path: "posts",
|
|
28
|
+
order: { by: "date", direction: "desc" },
|
|
24
29
|
versions: true,
|
|
25
30
|
fields: {
|
|
26
31
|
title: f.text().localized(),
|
|
27
32
|
slug: f.slug({ from: "title" }),
|
|
28
|
-
excerpt: f.text().
|
|
33
|
+
excerpt: f.text().localized(),
|
|
34
|
+
body: f.richtext().localized(),
|
|
35
|
+
tags: f.list(f.text()).localized(),
|
|
36
|
+
image: f.image(),
|
|
37
|
+
date: f.date(),
|
|
29
38
|
author: f.ref(() => people),
|
|
30
39
|
},
|
|
31
40
|
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { defineConfig, f } from "smoodly";
|
|
2
2
|
import { collections } from "./collections";
|
|
3
3
|
import { sections } from "./sections";
|
|
4
|
+
import { shared } from "./shared";
|
|
4
5
|
import { HomePage } from "@/components/pages/Home";
|
|
6
|
+
import { ArticlesPage } from "@/components/pages/Articles";
|
|
5
7
|
import { StandardPage } from "@/components/pages/Page";
|
|
6
8
|
import { ArticlePage } from "@/components/pages/Article";
|
|
7
9
|
|
|
8
|
-
export { collections, sections };
|
|
10
|
+
export { collections, sections, shared };
|
|
9
11
|
|
|
10
12
|
// The registry and the plain settings, one file. Runtime-free: nothing
|
|
11
13
|
// here imports a route file or Next, so a script, the CLI's template
|
|
@@ -14,14 +16,21 @@ export const config = defineConfig({
|
|
|
14
16
|
registry: {
|
|
15
17
|
sections,
|
|
16
18
|
collections,
|
|
17
|
-
|
|
19
|
+
shared,
|
|
20
|
+
pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
|
|
18
21
|
},
|
|
19
22
|
locales: { default: "en", supported: ["en"] },
|
|
20
23
|
// Smoodly's own default, spelled out: the record the site root serves.
|
|
21
24
|
homePageSlug: "home",
|
|
22
25
|
// Nesting on: an editor can put pages three deep.
|
|
23
26
|
pages: { tree: { depth: 3 } },
|
|
27
|
+
// Every section gets these two selects beside its own styles; the
|
|
28
|
+
// section wrapper emits them as data-spacing / data-tone, and
|
|
29
|
+
// app/globals.css styles both (DESIGN.md §3 "<smoodly.Section>").
|
|
24
30
|
styles: {
|
|
25
|
-
sections: {
|
|
31
|
+
sections: {
|
|
32
|
+
spacing: f.select(["sm", "md", "lg"]).default("md"),
|
|
33
|
+
tone: f.select(["paper", "ink"]).default("paper"),
|
|
34
|
+
},
|
|
26
35
|
},
|
|
27
36
|
});
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
// The site's section registrations, in one list. config.ts puts it
|
|
2
2
|
// in the registry and re-exports it, so the config, the stores (which walk
|
|
3
3
|
// trees against these schemas) and the renderer all share one array.
|
|
4
|
+
// Header and Footer are here because their shared items render through
|
|
5
|
+
// them; no zone lists them, so the "+" picker never offers them. Quote
|
|
6
|
+
// is both: its shared item renders through it, and the body zones list
|
|
7
|
+
// it so the shared quote can be placed (the picker offers a shared item
|
|
8
|
+
// only where its section is allowed).
|
|
4
9
|
import { Hero } from "@/components/sections/Hero";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
10
|
+
import { PageHero } from "@/components/sections/PageHero";
|
|
11
|
+
import { ImageText } from "@/components/sections/ImageText";
|
|
12
|
+
import { Points } from "@/components/sections/Points";
|
|
13
|
+
import { FavouriteArticles } from "@/components/sections/FavouriteArticles";
|
|
14
|
+
import { People } from "@/components/sections/People";
|
|
15
|
+
import { Quote } from "@/components/sections/Quote";
|
|
16
|
+
import { Header } from "@/components/sections/Header";
|
|
17
|
+
import { Footer } from "@/components/sections/Footer";
|
|
7
18
|
|
|
8
|
-
export const sections = [Hero,
|
|
19
|
+
export const sections = [Hero, PageHero, ImageText, Points, FavouriteArticles, People, Quote, Header, Footer];
|
|
@@ -2,31 +2,56 @@
|
|
|
2
2
|
// renderer, constructed lazily so `next build` doesn't need env vars at
|
|
3
3
|
// import time. The env shape is the package's (SMOODLY_*); whether the
|
|
4
4
|
// secret is a service-role key or a cloud write key is invisible here.
|
|
5
|
+
//
|
|
6
|
+
// Lazy on purpose, the site and renderer included: config.ts imports the
|
|
7
|
+
// page shapes, and a page shape may import this file to fetch through
|
|
8
|
+
// the site (components/pages/Articles.tsx). That is a cycle, and the
|
|
9
|
+
// admin route enters it from config.ts — so nothing here may read the
|
|
10
|
+
// config at module scope.
|
|
5
11
|
import {
|
|
6
|
-
createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex,
|
|
12
|
+
createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
|
|
7
13
|
} from "smoodly";
|
|
8
14
|
import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
|
|
9
15
|
import { createPageRenderer, type PageRegistration } from "smoodly/next";
|
|
10
16
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
11
|
-
import { collections, config, sections } from "./config";
|
|
17
|
+
import { collections, config, sections, shared } from "./config";
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
type Renderer = ReturnType<typeof createPageRenderer>;
|
|
20
|
+
type Bound = {
|
|
21
|
+
db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer;
|
|
22
|
+
};
|
|
14
23
|
|
|
15
|
-
|
|
24
|
+
let cached: Bound | null = null;
|
|
25
|
+
|
|
26
|
+
function bound(): Bound {
|
|
16
27
|
if (cached) return cached;
|
|
17
28
|
const env = smoodlyEnv();
|
|
18
29
|
const db = createSmoodlyClient(env);
|
|
19
30
|
// ONE path index for both stores: pages and entries share the URL space.
|
|
20
31
|
const paths = new SupabasePathIndex(db);
|
|
21
32
|
const locales = config.locales;
|
|
22
|
-
const entries = new SupabaseEntryStore(db, collections, { paths, locales });
|
|
33
|
+
const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared });
|
|
23
34
|
const pages = new SupabasePageStore(db, {
|
|
24
35
|
sections, entries, paths, locales,
|
|
25
36
|
homePageSlug: config.homePageSlug,
|
|
26
37
|
tree: config.pages?.tree,
|
|
27
38
|
});
|
|
28
39
|
const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
|
|
29
|
-
|
|
40
|
+
// Shadows the exported `stores` below on purpose: `createSmoodlySite`
|
|
41
|
+
// takes this local closure, not the export, so it never re-enters `bound()`.
|
|
42
|
+
const stores = () => ({ pages, entries });
|
|
43
|
+
// The site layer: (locale, path) → page or entry, and the content API
|
|
44
|
+
// (site.getPagesTree for menus, site.getEntries for lists).
|
|
45
|
+
// Shadows the exported `site` below on purpose: `createPageRenderer`
|
|
46
|
+
// takes this local value, not the export, so it never re-enters `bound()`.
|
|
47
|
+
const site = createSmoodlySite({ stores, collections, shared, sections, locales, homePageSlug: config.homePageSlug });
|
|
48
|
+
const renderer = createPageRenderer({
|
|
49
|
+
site,
|
|
50
|
+
sections,
|
|
51
|
+
pages: (config.registry.pages ?? []) as PageRegistration[],
|
|
52
|
+
href,
|
|
53
|
+
});
|
|
54
|
+
cached = { db, pages, entries, auth, site, renderer };
|
|
30
55
|
return cached;
|
|
31
56
|
}
|
|
32
57
|
|
|
@@ -53,20 +78,13 @@ export function publicAuth(): AuthConfig {
|
|
|
53
78
|
* becomes a locale-prefixing lookup. */
|
|
54
79
|
export const href = (path: string, _locale: string) => path;
|
|
55
80
|
|
|
56
|
-
/** The
|
|
57
|
-
*
|
|
58
|
-
export const site =
|
|
59
|
-
stores,
|
|
60
|
-
collections,
|
|
61
|
-
sections,
|
|
62
|
-
locales: config.locales,
|
|
63
|
-
homePageSlug: config.homePageSlug,
|
|
64
|
-
});
|
|
81
|
+
/** The content API for page views that fetch (the Articles index, an
|
|
82
|
+
* article's related list): `site().getEntries("articles", { locale })`. */
|
|
83
|
+
export const site = (): SmoodlySite => bound().site;
|
|
65
84
|
|
|
66
85
|
/** The two route files call these. */
|
|
67
|
-
export const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
86
|
+
export const renderSmoodlyPath: Renderer["renderSmoodlyPath"] = (...args) => bound().renderer.renderSmoodlyPath(...args);
|
|
87
|
+
export const smoodlyMetadata: Renderer["smoodlyMetadata"] = (...args) => bound().renderer.smoodlyMetadata(...args);
|
|
88
|
+
export const renderSmoodlyPage: Renderer["renderSmoodlyPage"] = (...args) => bound().renderer.renderSmoodlyPage(...args);
|
|
89
|
+
export const smoodlyPageMetadata: Renderer["smoodlyPageMetadata"] = (...args) => bound().renderer.smoodlyPageMetadata(...args);
|
|
90
|
+
export const getSmoodlyShared: Renderer["getSmoodlyShared"] = (...args) => bound().renderer.getSmoodlyShared(...args);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Shared content (spec 2026-09-07): code-declared singletons stored as
|
|
2
|
+
// entries. `header` and `footer` are visual — the layout renders them;
|
|
3
|
+
// `contact` is an object the footer reads through the layout; `quote` is
|
|
4
|
+
// visual and placed on pages from the picker's Shared group.
|
|
5
|
+
import { f, smoodly } from "smoodly";
|
|
6
|
+
import { Header } from "@/components/sections/Header";
|
|
7
|
+
import { Footer } from "@/components/sections/Footer";
|
|
8
|
+
import { Quote } from "@/components/sections/Quote";
|
|
9
|
+
|
|
10
|
+
export const header = smoodly.shared({ name: "header", title: "Header", section: Header });
|
|
11
|
+
|
|
12
|
+
export const footer = smoodly.shared({ name: "footer", title: "Footer", section: Footer, versions: true });
|
|
13
|
+
|
|
14
|
+
export const contact = smoodly.shared({
|
|
15
|
+
name: "contact",
|
|
16
|
+
title: "Contact details",
|
|
17
|
+
fields: {
|
|
18
|
+
email: f.text(),
|
|
19
|
+
phone: f.text().optional(),
|
|
20
|
+
address: f.text().localized(),
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const quote = smoodly.shared({ name: "quote", title: "Quote", section: Quote });
|
|
25
|
+
|
|
26
|
+
export const shared = [header, footer, contact, quote];
|
|
@@ -63,22 +63,6 @@ create table if not exists "page_versions" (
|
|
|
63
63
|
create index if not exists "page_versions_page_locale_idx"
|
|
64
64
|
on "page_versions" ("page_id", "locale", "created_at");
|
|
65
65
|
|
|
66
|
-
create table if not exists "saved_sections" (
|
|
67
|
-
"id" uuid primary key default gen_random_uuid(),
|
|
68
|
-
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
69
|
-
"title" text not null,
|
|
70
|
-
"fragment" jsonb not null,
|
|
71
|
-
"created_at" timestamptz not null default now()
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
create table if not exists "global_sections" (
|
|
75
|
-
"id" uuid primary key default gen_random_uuid(),
|
|
76
|
-
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
77
|
-
"type" text not null,
|
|
78
|
-
"content" jsonb not null,
|
|
79
|
-
"updated_at" timestamptz not null default now()
|
|
80
|
-
);
|
|
81
|
-
|
|
82
66
|
create table if not exists "entries" (
|
|
83
67
|
"id" uuid primary key default gen_random_uuid(),
|
|
84
68
|
"space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
|
|
@@ -204,8 +188,6 @@ $$;
|
|
|
204
188
|
alter table "pages" enable row level security;
|
|
205
189
|
alter table "page_locales" enable row level security;
|
|
206
190
|
alter table "page_versions" enable row level security;
|
|
207
|
-
alter table "saved_sections" enable row level security;
|
|
208
|
-
alter table "global_sections" enable row level security;
|
|
209
191
|
alter table "entries" enable row level security;
|
|
210
192
|
alter table "entry_locales" enable row level security;
|
|
211
193
|
alter table "entry_versions" enable row level security;
|
|
@@ -313,10 +295,6 @@ create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_
|
|
|
313
295
|
|
|
314
296
|
create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
|
|
315
297
|
|
|
316
|
-
create index if not exists "saved_sections_space_idx" on "saved_sections" ("space_id");
|
|
317
|
-
|
|
318
|
-
create index if not exists "global_sections_space_idx" on "global_sections" ("space_id");
|
|
319
|
-
|
|
320
298
|
create index if not exists "refs_space_idx" on "refs" ("space_id");
|
|
321
299
|
|
|
322
300
|
drop trigger if exists "smoodly_a_set_space" on "pages";
|
|
@@ -331,14 +309,6 @@ drop trigger if exists "smoodly_a_set_space" on "page_versions";
|
|
|
331
309
|
create trigger "smoodly_a_set_space" before insert on "page_versions"
|
|
332
310
|
for each row execute function "smoodly_set_space"();
|
|
333
311
|
|
|
334
|
-
drop trigger if exists "smoodly_a_set_space" on "saved_sections";
|
|
335
|
-
create trigger "smoodly_a_set_space" before insert on "saved_sections"
|
|
336
|
-
for each row execute function "smoodly_set_space"();
|
|
337
|
-
|
|
338
|
-
drop trigger if exists "smoodly_a_set_space" on "global_sections";
|
|
339
|
-
create trigger "smoodly_a_set_space" before insert on "global_sections"
|
|
340
|
-
for each row execute function "smoodly_set_space"();
|
|
341
|
-
|
|
342
312
|
drop trigger if exists "smoodly_a_set_space" on "entries";
|
|
343
313
|
create trigger "smoodly_a_set_space" before insert on "entries"
|
|
344
314
|
for each row execute function "smoodly_set_space"();
|
|
@@ -375,14 +345,6 @@ drop trigger if exists "smoodly_b_row_cap" on "page_versions";
|
|
|
375
345
|
create trigger "smoodly_b_row_cap" before insert on "page_versions"
|
|
376
346
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
377
347
|
|
|
378
|
-
drop trigger if exists "smoodly_b_row_cap" on "saved_sections";
|
|
379
|
-
create trigger "smoodly_b_row_cap" before insert on "saved_sections"
|
|
380
|
-
for each row execute function "smoodly_enforce_row_cap"();
|
|
381
|
-
|
|
382
|
-
drop trigger if exists "smoodly_b_row_cap" on "global_sections";
|
|
383
|
-
create trigger "smoodly_b_row_cap" before insert on "global_sections"
|
|
384
|
-
for each row execute function "smoodly_enforce_row_cap"();
|
|
385
|
-
|
|
386
348
|
drop trigger if exists "smoodly_b_row_cap" on "entries";
|
|
387
349
|
create trigger "smoodly_b_row_cap" before insert on "entries"
|
|
388
350
|
for each row execute function "smoodly_enforce_row_cap"();
|
|
@@ -430,20 +392,6 @@ drop policy if exists "smoodly_read" on "page_versions";
|
|
|
430
392
|
create policy "smoodly_read" on "page_versions" for select
|
|
431
393
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id"));
|
|
432
394
|
|
|
433
|
-
drop policy if exists "smoodly_write" on "saved_sections";
|
|
434
|
-
create policy "smoodly_write" on "saved_sections" for all
|
|
435
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
436
|
-
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
437
|
-
|
|
438
|
-
drop policy if exists "smoodly_write" on "global_sections";
|
|
439
|
-
create policy "smoodly_write" on "global_sections" for all
|
|
440
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
441
|
-
with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
442
|
-
|
|
443
|
-
drop policy if exists "smoodly_read" on "global_sections";
|
|
444
|
-
create policy "smoodly_read" on "global_sections" for select
|
|
445
|
-
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()));
|
|
446
|
-
|
|
447
395
|
drop policy if exists "smoodly_write" on "entries";
|
|
448
396
|
create policy "smoodly_write" on "entries" for all
|
|
449
397
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
|
|
@@ -514,8 +462,15 @@ select
|
|
|
514
462
|
l."updated_at",
|
|
515
463
|
v."fields"->>'title' as "title",
|
|
516
464
|
v."fields"->>'excerpt' as "excerpt",
|
|
465
|
+
v."fields"->'body' as "body",
|
|
466
|
+
v."fields"->'tags' as "tags",
|
|
467
|
+
v."fields"->'image' as "image",
|
|
468
|
+
(v."fields"->>'date')::timestamptz as "date",
|
|
517
469
|
v."fields"->>'author' as "author"
|
|
518
470
|
from "entries" e
|
|
519
471
|
join "entry_locales" l on l."entry_id" = e."id"
|
|
520
472
|
join "entry_versions" v on v."id" = l."published_version_id"
|
|
521
473
|
where e."collection" = 'articles' and l."status" = 'published';
|
|
474
|
+
|
|
475
|
+
create index if not exists "entries_articles_date_idx"
|
|
476
|
+
on "entries" (("fields"->'date')) where "collection" = 'articles';
|