create-smoodly-app 0.0.8 → 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.
Files changed (74) hide show
  1. package/dist/index.js +22 -2
  2. package/package.json +1 -1
  3. package/templates/next/README.md +18 -2
  4. package/templates/next/app/globals.css +190 -0
  5. package/templates/next/app/layout.tsx +17 -9
  6. package/templates/next/components/ArticleCard.tsx +22 -0
  7. package/templates/next/components/articles.ts +38 -0
  8. package/templates/next/components/pages/Article.tsx +58 -10
  9. package/templates/next/components/pages/Articles.tsx +44 -0
  10. package/templates/next/components/pages/Home.tsx +12 -11
  11. package/templates/next/components/pages/Page.tsx +11 -9
  12. package/templates/next/components/sections/FavouriteArticles.tsx +42 -0
  13. package/templates/next/components/sections/Footer.tsx +50 -14
  14. package/templates/next/components/sections/Header.tsx +44 -0
  15. package/templates/next/components/sections/Hero.tsx +32 -7
  16. package/templates/next/components/sections/ImageText.tsx +59 -0
  17. package/templates/next/components/sections/PageHero.tsx +38 -0
  18. package/templates/next/components/sections/People.tsx +36 -0
  19. package/templates/next/components/sections/Points.tsx +50 -0
  20. package/templates/next/components/sections/Quote.tsx +33 -0
  21. package/templates/next/components/strings.ts +23 -0
  22. package/templates/next/messages/en.json +5 -0
  23. package/templates/next/package.json +4 -1
  24. package/templates/next/public/images/about_main.jpg +0 -0
  25. package/templates/next/public/images/article_photo.jpg +0 -0
  26. package/templates/next/public/images/hero_image.jpg +0 -0
  27. package/templates/next/public/images/home_section_2.jpg +0 -0
  28. package/templates/next/scripts/seed.ts +310 -0
  29. package/templates/next/smoodly/collections.ts +12 -3
  30. package/templates/next/smoodly/config.ts +9 -2
  31. package/templates/next/smoodly/sections.ts +13 -4
  32. package/templates/next/smoodly/server.ts +37 -20
  33. package/templates/next/smoodly/shared.ts +9 -5
  34. package/templates/next/supabase/migrations/00000000000000_smoodly.sql +7 -0
  35. package/templates/next-intl/README.md +19 -3
  36. package/templates/next-intl/app/[locale]/layout.tsx +24 -12
  37. package/templates/next-intl/app/globals.css +190 -0
  38. package/templates/next-intl/components/ArticleCard.tsx +22 -0
  39. package/templates/next-intl/components/articles.ts +38 -0
  40. package/templates/next-intl/components/pages/Article.tsx +58 -10
  41. package/templates/next-intl/components/pages/Articles.tsx +44 -0
  42. package/templates/next-intl/components/pages/Home.tsx +12 -11
  43. package/templates/next-intl/components/pages/Page.tsx +11 -9
  44. package/templates/next-intl/components/sections/FavouriteArticles.tsx +42 -0
  45. package/templates/next-intl/components/sections/Footer.tsx +50 -14
  46. package/templates/next-intl/components/sections/Header.tsx +44 -0
  47. package/templates/next-intl/components/sections/Hero.tsx +32 -7
  48. package/templates/next-intl/components/sections/ImageText.tsx +59 -0
  49. package/templates/next-intl/components/sections/PageHero.tsx +38 -0
  50. package/templates/next-intl/components/sections/People.tsx +36 -0
  51. package/templates/next-intl/components/sections/Points.tsx +50 -0
  52. package/templates/next-intl/components/sections/Quote.tsx +33 -0
  53. package/templates/next-intl/components/strings.ts +23 -0
  54. package/templates/next-intl/i18n/request.ts +14 -7
  55. package/templates/next-intl/messages/en.json +5 -0
  56. package/templates/next-intl/messages/fi.json +5 -0
  57. package/templates/next-intl/package.json +4 -1
  58. package/templates/next-intl/public/images/about_main.jpg +0 -0
  59. package/templates/next-intl/public/images/article_photo.jpg +0 -0
  60. package/templates/next-intl/public/images/hero_image.jpg +0 -0
  61. package/templates/next-intl/public/images/home_section_2.jpg +0 -0
  62. package/templates/next-intl/scripts/seed.ts +310 -0
  63. package/templates/next-intl/smoodly/collections.ts +13 -4
  64. package/templates/next-intl/smoodly/config.ts +9 -2
  65. package/templates/next-intl/smoodly/sections.ts +13 -4
  66. package/templates/next-intl/smoodly/server.ts +37 -20
  67. package/templates/next-intl/smoodly/shared.ts +9 -5
  68. package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +7 -0
  69. package/templates/next/components/sections/ArticleList.tsx +0 -31
  70. package/templates/next/components/sections/CtaBanner.tsx +0 -35
  71. package/templates/next/components/sections/Testimonials.tsx +0 -30
  72. package/templates/next-intl/components/sections/ArticleList.tsx +0 -31
  73. package/templates/next-intl/components/sections/CtaBanner.tsx +0 -35
  74. package/templates/next-intl/components/sections/Testimonials.tsx +0 -30
package/dist/index.js CHANGED
@@ -203,6 +203,12 @@ async function isEmptyOrMissing(dir) {
203
203
  }
204
204
  var ROUTING_LOCALES = ' locales: ["en", "fi"],';
205
205
  var ROUTING_DEFAULT = ' defaultLocale: "en",';
206
+ var ARTICLES_PATH_LINE = ' path: { en: "posts", fi: "artikkelit" },';
207
+ var ARTICLES_SLUG_LINE = ' slug: { en: "posts", fi: "artikkelit" },';
208
+ function articlesSegments(locales) {
209
+ const entries = locales.map((l) => `${/^[a-z]+$/.test(l) ? l : JSON.stringify(l)}: ${l === "fi" ? '"artikkelit"' : '"posts"'}`);
210
+ return `{ ${entries.join(", ")} }`;
211
+ }
206
212
  async function scaffold(opts) {
207
213
  await cp(opts.templateDir, opts.dir, { recursive: true });
208
214
  await rename(join(opts.dir, "_gitignore"), join(opts.dir, ".gitignore"));
@@ -221,6 +227,14 @@ async function scaffold(opts) {
221
227
  routingPath,
222
228
  routing.replace(ROUTING_LOCALES, ` locales: [${opts.locales.map((l) => JSON.stringify(l)).join(", ")}],`).replace(ROUTING_DEFAULT, ` defaultLocale: ${JSON.stringify(opts.locales[0])},`)
223
229
  );
230
+ const map = articlesSegments(opts.locales);
231
+ for (const [rel, line] of [["smoodly/collections.ts", ARTICLES_PATH_LINE], ["components/pages/Articles.tsx", ARTICLES_SLUG_LINE]]) {
232
+ const path = join(opts.dir, rel);
233
+ const source = await readFile(path, "utf8");
234
+ if (!source.includes(line)) throw new Error(`${rel} in the template has drifted; expected the line
235
+ ${line}`);
236
+ await writeFile(path, source.replace(line, line.replace('{ en: "posts", fi: "artikkelit" }', map)));
237
+ }
224
238
  }
225
239
  }
226
240
 
@@ -331,7 +345,7 @@ function plan(a) {
331
345
  steps.push("supabaseInit");
332
346
  if (a.hosted?.linkAndPush) steps.push("linkAndPush");
333
347
  }
334
- steps.push("writeEnv", "ensureEditor", "finish");
348
+ steps.push("writeEnv", "ensureEditor", "seed", "finish");
335
349
  return steps;
336
350
  }
337
351
 
@@ -438,6 +452,10 @@ async function run(answers2, deps) {
438
452
  case "ensureEditor":
439
453
  ctx.editor = await ensureEditor(deps.editorClient(ctx.keys), deps.askEditor);
440
454
  break;
455
+ case "seed":
456
+ deps.log("Seeding the starter content\u2026");
457
+ await sh("npm", ["run", "seed"]);
458
+ break;
441
459
  case "finish":
442
460
  break;
443
461
  }
@@ -482,6 +500,8 @@ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimE
482
500
  "",
483
501
  editorLines
484
502
  ].join("\n");
503
+ case "seed":
504
+ return `cd ${answers2.name} && npm run seed`;
485
505
  default:
486
506
  return "";
487
507
  }
@@ -549,7 +569,7 @@ p2.note(
549
569
  `cd ${answers.name}`,
550
570
  "npm run dev",
551
571
  "",
552
- "Site: http://localhost:3000",
572
+ "Site: http://localhost:3000 (the Lorem Co. starter \u2014 edit it at /admin)",
553
573
  "Admin: http://localhost:3000/admin",
554
574
  `Editor: ${outcome.editor.email ?? outcome.editor.id}`,
555
575
  ...answers.locales ? [`Locales: ${answers.locales.join(", ")} (i18n/routing.ts)`] : []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Create a Smoodly site: a fresh Next.js app with the admin mounted, a Supabase schema, an env file and a first editor.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,12 +9,28 @@ mounted at `/admin` from the `smoodly` package.
9
9
 
10
10
  - `app/page.tsx` — the home page: one line calling renderSmoodlyPath([]).
11
11
  - `app/[...path]/page.tsx` — every other page and every collection entry, at any depth.
12
- - `components/pages/` — the page shapes and entry pages: schema + view, one file each.
13
- - `components/sections/` — the building blocks editors compose with. Add one, list it in `smoodly/sections.ts`.
12
+ - `app/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the header and footer from shared content.
13
+ - `components/pages/` — the page shapes and the entry page: `Home` (locked hero, freeform body), `Articles` (a fixed page at the collection's path — its index — with a code-fetched grid), `Page` (open; About lives here), `Article` (the entry page).
14
+ - `components/sections/` — the building blocks editors compose with, and the header, footer and quote behind the shared items. Add one, list it in `smoodly/sections.ts`.
15
+ - `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
16
+ - `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
17
+ - `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
14
18
  - `smoodly/collections.ts` — structured content (people, articles). Add a collection, then add its view to a new migration with `collectionSQL` from `smoodly`.
15
19
  - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts`, `sections.ts`, `server.ts` (stores, site layer, renderer), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount).
16
20
  - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
17
21
 
22
+ ## What is in the starter
23
+
24
+ The Lorem Co. site the installer seeded is a tour of the features:
25
+
26
+ - **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the shared **quote** placed from the picker's Shared group, points (a list with a minimum and a maximum, an object per item), favourite articles (an editor-picked list of refs).
27
+ - **Articles** — a fixed page at the collection's own URL segment, so it is the collection's index: the hero is the editor's, the grid is code fetching published articles.
28
+ - **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
29
+ - **An article** — a collection entry with a richtext body, tags (a localized list), a date and an author ref; versioned, so every save is a snapshot.
30
+ - **Shared content** — the header and footer the layout renders, the contact details the footer reads, the quote pages place.
31
+
32
+ Reseed an empty database any time with `npm run seed`.
33
+
18
34
  ## Editors
19
35
 
20
36
  Anyone who may open `/admin` is a Supabase Auth user with a row in the
@@ -0,0 +1,190 @@
1
+ /* Lorem Co. — the starter's one stylesheet.
2
+ Tokens from the design, base rules, then one block per section and
3
+ page in page order. Sections carry data-spacing and data-tone from
4
+ the config's section styles; the site header and footer are shared
5
+ items rendered by the layout. Photos crop with object-fit: cover at
6
+ the design's aspect ratios. */
7
+
8
+ :root {
9
+ --paper: #faf7f2;
10
+ --ink: #1f1b17;
11
+ --accent: #d9653a;
12
+ --muted: #6b6259;
13
+ --line: #e8e1d7;
14
+ --line-strong: #d6cdc1;
15
+ --body: #3a342e;
16
+ --radius: 6px;
17
+ --container: 1200px;
18
+ --serif: var(--font-serif), Georgia, serif;
19
+ --sans: var(--font-sans), Helvetica, Arial, sans-serif;
20
+ }
21
+
22
+ /* ---------- base ---------- */
23
+
24
+ * { box-sizing: border-box; }
25
+ html, body { margin: 0; padding: 0; }
26
+ body {
27
+ background: var(--paper);
28
+ color: var(--ink);
29
+ font-family: var(--sans);
30
+ -webkit-font-smoothing: antialiased;
31
+ min-height: 100vh;
32
+ display: flex;
33
+ flex-direction: column;
34
+ }
35
+ main { flex: 1; }
36
+ a { color: inherit; text-decoration: none; }
37
+ a:hover { color: var(--accent); }
38
+ img { display: block; max-width: 100%; }
39
+ h1, h2, h3, p, blockquote, address { margin: 0; }
40
+ address { font-style: normal; }
41
+
42
+ .container { max-width: var(--container); width: 100%; margin: 0 auto; padding-inline: 32px; }
43
+
44
+ .kicker { font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); font-weight: 600; }
45
+ .h2 { font-family: var(--serif); font-weight: 400; font-size: clamp(30px, 3.5vw, 44px); line-height: 1.1; letter-spacing: -0.02em; text-wrap: balance; }
46
+ .link-accent { color: var(--accent); font-weight: 500; font-size: 15px; }
47
+ .link-accent:hover { color: var(--ink); }
48
+
49
+ .button { display: inline-block; background: var(--ink); color: var(--paper); padding: 14px 24px; border-radius: var(--radius); font-size: 15px; font-weight: 500; }
50
+ .button:hover { background: var(--accent); color: var(--paper); }
51
+ .button--outline { background: transparent; color: var(--ink); border: 1px solid var(--line-strong); }
52
+ .button--outline:hover { background: transparent; color: var(--ink); border-color: var(--ink); }
53
+
54
+ /* Richtext (RichText renders bare elements) */
55
+ .prose { display: flex; flex-direction: column; gap: 20px; font-size: 17px; line-height: 1.65; color: var(--muted); }
56
+ .prose p { text-wrap: pretty; }
57
+ .prose h2, .prose h3 { font-family: var(--serif); font-weight: 500; color: var(--ink); line-height: 1.2; letter-spacing: -0.01em; }
58
+ .prose h2 { font-size: 32px; margin-top: 8px; }
59
+ .prose h3 { font-size: 24px; }
60
+ .prose ul, .prose ol { margin: 0; padding-left: 22px; display: flex; flex-direction: column; gap: 8px; }
61
+ .prose blockquote { margin: 8px 0; padding-left: 24px; border-left: 2px solid var(--accent); font-family: var(--serif); font-style: italic; font-size: 24px; line-height: 1.4; color: var(--ink); }
62
+ .prose a { color: var(--accent); text-decoration: underline; }
63
+ .prose--lead { gap: 24px; font-size: 18px; line-height: 1.7; color: var(--body); }
64
+ .prose--lead > p:first-child { font-family: var(--serif); font-size: 24px; line-height: 1.45; color: var(--ink); }
65
+
66
+ /* ---------- section styles: the config's spacing and tone ---------- */
67
+
68
+ /* Every section in the page body gets the medium spacing by element,
69
+ not by attribute: a node saved without an explicit style carries no
70
+ data-spacing (the config's default is not filled at render), so the
71
+ attribute selectors below only handle the two non-default values. */
72
+ main > section { padding-block: 56px; }
73
+ [data-spacing="sm"] { padding-block: 32px; }
74
+ [data-spacing="lg"] { padding-block: 88px; }
75
+ [data-tone="ink"] { background: var(--ink); color: var(--paper); }
76
+ [data-tone="ink"] .prose, [data-tone="ink"] .hero__text, [data-tone="ink"] .page-hero__text,
77
+ [data-tone="ink"] .point__text, [data-tone="ink"] .card__meta, [data-tone="ink"] .card__excerpt,
78
+ [data-tone="ink"] .person__role, [data-tone="ink"] .quote__attribution { color: var(--line); }
79
+ [data-tone="ink"] .button { background: var(--paper); color: var(--ink); }
80
+ [data-tone="ink"] .button--outline { background: transparent; color: var(--paper); border-color: var(--muted); }
81
+
82
+ /* ---------- header ---------- */
83
+
84
+ .site-header__bar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-block: 28px; }
85
+ .site-header__brand { font-family: var(--serif); font-size: 26px; font-weight: 500; letter-spacing: -0.01em; }
86
+ .site-header__nav { display: flex; gap: 32px; font-size: 15px; margin-left: auto; }
87
+ .site-header__locales { display: flex; gap: 12px; font-size: 13px; letter-spacing: 0.08em; color: var(--muted); }
88
+ .site-header__locales a[aria-current] { color: var(--ink); font-weight: 600; }
89
+
90
+ /* ---------- hero (home) ---------- */
91
+
92
+ .hero__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 48px; align-items: center; }
93
+ .hero__copy { display: flex; flex-direction: column; gap: 24px; }
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
+ .hero__text { font-size: 18px; line-height: 1.6; color: var(--muted); max-width: 520px; text-wrap: pretty; }
96
+ .hero__buttons { display: flex; gap: 12px; flex-wrap: wrap; }
97
+ .hero__image { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; border-radius: var(--radius); }
98
+
99
+ /* ---------- page hero ---------- */
100
+
101
+ .page-hero__inner { display: flex; flex-direction: column; gap: 40px; }
102
+ .page-hero__copy { display: flex; flex-direction: column; gap: 20px; max-width: 760px; }
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
+ .page-hero__text { font-size: 18px; line-height: 1.6; color: var(--muted); text-wrap: pretty; }
105
+ .page-hero__image { width: 100%; aspect-ratio: 21 / 9; object-fit: cover; border-radius: var(--radius); }
106
+
107
+ /* ---------- image and text ---------- */
108
+
109
+ .image-text__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 48px; align-items: center; }
110
+ .image-text__image { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius); }
111
+ [data-image-side="right"] .image-text__image { order: 2; }
112
+ .image-text__copy { display: flex; flex-direction: column; gap: 20px; }
113
+ .image-text__grid > .h2 { align-self: start; }
114
+
115
+ /* ---------- quote ---------- */
116
+
117
+ .quote { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
118
+ .quote__inner { max-width: 880px; margin: 0 auto; padding-inline: 32px; text-align: center; display: flex; flex-direction: column; gap: 24px; align-items: center; }
119
+ .quote__text { font-family: var(--serif); font-style: italic; font-weight: 300; font-size: clamp(26px, 3vw, 38px); line-height: 1.3; letter-spacing: -0.01em; text-wrap: balance; }
120
+ .quote__attribution { font-size: 14px; color: var(--muted); letter-spacing: 0.04em; }
121
+
122
+ /* ---------- points ---------- */
123
+
124
+ .points .container { display: flex; flex-direction: column; gap: 48px; }
125
+ .points__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 40px; }
126
+ .point { display: flex; flex-direction: column; gap: 16px; }
127
+ .point__icon { color: var(--accent); }
128
+ .point__heading { font-family: var(--serif); font-weight: 500; font-size: 24px; }
129
+ .point__text { font-size: 16px; line-height: 1.6; color: var(--muted); text-wrap: pretty; }
130
+
131
+ /* ---------- article cards (favourites, index, related) ---------- */
132
+
133
+ .favourites .container, .related .container { display: flex; flex-direction: column; gap: 40px; }
134
+ .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 40px 32px; }
135
+ .card-grid--3 { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
136
+ .card { display: flex; flex-direction: column; gap: 14px; color: inherit; }
137
+ .card:hover { color: inherit; }
138
+ .card:hover .card__title { color: var(--accent); }
139
+ .card__image { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; border-radius: var(--radius); }
140
+ .card__meta { display: flex; gap: 10px; font-size: 13px; color: var(--muted); }
141
+ .card__tag { color: var(--accent); font-weight: 600; }
142
+ .card__title { font-family: var(--serif); font-weight: 500; font-size: 26px; line-height: 1.2; letter-spacing: -0.01em; text-wrap: balance; }
143
+ h3.card__title { font-size: 24px; }
144
+ .card__excerpt { font-size: 15px; line-height: 1.6; color: var(--muted); text-wrap: pretty; }
145
+
146
+ /* ---------- people ---------- */
147
+
148
+ .people .container { display: flex; flex-direction: column; gap: 40px; }
149
+ .people__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 40px 32px; }
150
+ .person { display: flex; flex-direction: column; gap: 12px; }
151
+ .person__mark { width: 56px; height: 56px; border-radius: 50%; background: var(--line); color: var(--ink); display: inline-flex; align-items: center; justify-content: center; font-family: var(--serif); font-size: 24px; }
152
+ .person__name { font-family: var(--serif); font-size: 22px; font-weight: 500; }
153
+ .person__role { font-size: 14px; color: var(--muted); }
154
+
155
+ /* ---------- pages ---------- */
156
+
157
+ .breadcrumb { padding-top: 24px; font-size: 14px; color: var(--muted); }
158
+ .breadcrumb a:hover { color: var(--accent); }
159
+
160
+ .articles-index { padding-bottom: 96px; }
161
+
162
+ .article { padding-top: 48px; display: flex; flex-direction: column; gap: 40px; }
163
+ .article__back { font-size: 14px; color: var(--muted); }
164
+ .article__head { max-width: 760px; display: flex; flex-direction: column; gap: 20px; }
165
+ .article__tags { display: flex; gap: 10px; flex-wrap: wrap; }
166
+ .tag { font-size: 13px; font-weight: 600; color: var(--muted); border: 1px solid var(--line-strong); border-radius: var(--radius); padding: 4px 10px; }
167
+ .tag--accent { color: var(--accent); border-color: var(--accent); }
168
+ .article__title { font-family: var(--serif); font-weight: 400; font-size: clamp(38px, 4.5vw, 60px); line-height: 1.08; letter-spacing: -0.02em; text-wrap: balance; }
169
+ .article__byline { display: flex; align-items: center; gap: 12px; font-size: 14px; color: var(--muted); }
170
+ .article__byline strong { color: var(--ink); font-weight: 500; }
171
+ .article__avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--line); display: inline-block; flex: none; }
172
+ .article__image { width: 100%; aspect-ratio: 21 / 9; object-fit: cover; border-radius: var(--radius); }
173
+ .article__body { max-width: 720px; margin: 16px auto 0; width: 100%; }
174
+
175
+ .related { border-top: 1px solid var(--line); margin-top: 88px; padding: 64px 0 96px; }
176
+ .related__head { display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: wrap; }
177
+
178
+ /* ---------- footer ---------- */
179
+
180
+ .site-footer { border-top: 1px solid var(--line); margin-top: auto; }
181
+ .site-footer__grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; padding: 56px 32px 40px; font-size: 14px; }
182
+ @media (max-width: 720px) { .site-footer__grid { grid-template-columns: 1fr 1fr; } }
183
+ .site-footer__about { display: flex; flex-direction: column; gap: 12px; }
184
+ .site-footer__tagline { color: var(--muted); line-height: 1.6; max-width: 300px; }
185
+ .site-footer__contact { display: flex; flex-direction: column; gap: 4px; color: var(--muted); }
186
+ .site-footer__column { display: flex; flex-direction: column; gap: 10px; }
187
+ .site-footer__column a { color: var(--muted); }
188
+ .site-footer__column a:hover { color: var(--accent); }
189
+ .site-footer__title { font-weight: 600; }
190
+ .site-footer__copyright { padding: 0 32px 32px; font-size: 13px; color: var(--muted); }
@@ -1,26 +1,34 @@
1
1
  import type { ReactNode } from "react";
2
- import { Footer } from "@/components/sections/Footer";
3
- import { contact, footer } from "@/smoodly/shared";
2
+ import { Instrument_Sans, Newsreader } from "next/font/google";
3
+ import { HeaderView } from "@/components/sections/Header";
4
+ import { FooterView } from "@/components/sections/Footer";
5
+ import { contact, footer, header } from "@/smoodly/shared";
4
6
  import { getSmoodlyShared } from "@/smoodly/server";
7
+ import "./globals.css";
5
8
 
6
- export const metadata = { title: "Smoodly site" };
9
+ // The starter's two faces, as CSS variables globals.css reads.
10
+ const serif = Newsreader({ subsets: ["latin", "latin-ext"], style: ["normal", "italic"], variable: "--font-serif" });
11
+ const sans = Instrument_Sans({ subsets: ["latin", "latin-ext"], weight: ["400", "500", "600"], variable: "--font-sans" });
12
+
13
+ export const metadata = { title: "Lorem Co." };
7
14
 
8
15
  // The site's root layout. The admin has its own under app/(smoodly)/admin —
9
16
  // two root layouts, a full page load between them, which is fine: they
10
- // are separate apps in spirit. The footer and the contact block are
17
+ // are separate apps in spirit. Header, footer and the contact block are
11
18
  // shared content: fetched here, cached under their tag, null until
12
19
  // published.
13
20
  export default async function RootLayout({ children }: { children: ReactNode }) {
14
- const [footerProps, contactFields] = await Promise.all([
21
+ const [headerProps, footerProps, contactFields] = await Promise.all([
22
+ getSmoodlyShared(header),
15
23
  getSmoodlyShared(footer),
16
24
  getSmoodlyShared(contact),
17
25
  ]);
18
26
  return (
19
- <html lang="en">
27
+ <html lang="en" className={`${serif.variable} ${sans.variable}`}>
20
28
  <body>
21
- {children}
22
- {contactFields && <address>{contactFields.email} · {contactFields.address}</address>}
23
- {footerProps && <Footer {...footerProps} />}
29
+ {headerProps && <HeaderView {...headerProps} />}
30
+ <main>{children}</main>
31
+ {footerProps && <FooterView {...footerProps} contact={contactFields} />}
24
32
  </body>
25
33
  </html>
26
34
  );
@@ -0,0 +1,22 @@
1
+ import { formatDate, type ArticleFields } from "./articles";
2
+
3
+ // One article card: photo, first tag and date, title, excerpt. The grid
4
+ // item on the Articles page and in Favourite articles; `compact` drops
5
+ // the excerpt for the Related list. Pure — the caller resolves the URL
6
+ // and the locale.
7
+ export function ArticleCard({ article, url, locale, compact = false }: {
8
+ article: ArticleFields; url: string; locale: string; compact?: boolean;
9
+ }) {
10
+ const Title = compact ? "h3" : "h2";
11
+ return (
12
+ <a className="card" href={url}>
13
+ {article.image?.url && <img className="card__image" src={article.image.url} alt={article.image.alt ?? ""} />}
14
+ <div className="card__meta">
15
+ {article.tags?.[0] && <span className="card__tag">{article.tags[0]}</span>}
16
+ {article.date && <span>{formatDate(article.date, locale)}</span>}
17
+ </div>
18
+ <Title className="card__title">{article.title}</Title>
19
+ {!compact && article.excerpt && <p className="card__excerpt">{article.excerpt}</p>}
20
+ </a>
21
+ );
22
+ }
@@ -0,0 +1,38 @@
1
+ // Small helpers shared by the Articles index, the article page and the
2
+ // Favourite articles section — the article's URL, dates in the request
3
+ // locale, and the read time from the richtext body.
4
+ import { articles } from "@/smoodly/collections";
5
+
6
+ export type ArticleFields = {
7
+ title: string;
8
+ slug: string;
9
+ excerpt?: string;
10
+ body?: unknown;
11
+ tags?: string[];
12
+ image?: { url: string; alt?: string };
13
+ date: string;
14
+ };
15
+
16
+ /** The collection's segment in a locale: its per-locale path, or the
17
+ * first declared one for a locale the map does not name. */
18
+ function segment(locale: string): string {
19
+ const p = articles.path;
20
+ if (typeof p === "string") return p;
21
+ return p?.[locale] ?? Object.values(p ?? {})[0] ?? "posts";
22
+ }
23
+
24
+ export const articlesIndexPath = (locale: string) => `/${segment(locale)}`;
25
+ export const articlePath = (slug: string, locale: string) => `/${segment(locale)}/${slug}`;
26
+
27
+ export const formatDate = (iso: string, locale: string) =>
28
+ new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(new Date(iso));
29
+
30
+ type Node = { text?: string; content?: Node[] };
31
+ export function richtextText(doc: unknown): string {
32
+ const walk = (n: Node): string => n.text ?? (n.content ?? []).map(walk).join(" ");
33
+ return walk((doc ?? {}) as Node);
34
+ }
35
+
36
+ /** Words / 200, at least one minute. */
37
+ export const readingMinutes = (doc: unknown) =>
38
+ Math.max(1, Math.round(richtextText(doc).split(/\s+/).filter(Boolean).length / 200));
@@ -1,15 +1,63 @@
1
- import { smoodly } from "smoodly";
1
+ import { RichText, smoodly, type EntryPageProps } from "smoodly";
2
2
  import { articles } from "@/smoodly/collections";
3
+ import { site } from "@/smoodly/server";
4
+ import { ArticleCard } from "../ArticleCard";
5
+ import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
6
+ import { ui } from "../strings";
3
7
 
4
8
  // The entry page for `articles`, served at the collection's `path`
5
- // (/posts/<slug>). Fields only; refs arrive resolved (author is a person).
6
- export const ArticlePage = smoodly.entryPage(articles, ({ entry, path }) => {
7
- const a = entry.fields as { title: string; excerpt?: string; author?: { name: string; role?: string } | null };
9
+ // (/posts/<slug>, /fi/artikkelit/<slug>). Fields only; refs arrive
10
+ // resolved (author is a person). The body is richtext rendered by the
11
+ // package's RichText; the read time is words / 200. "Related articles"
12
+ // is the newest other published articles, fetched here, and absent when
13
+ // there are none.
14
+ async function ArticleView({ entry }: EntryPageProps) {
15
+ const { locale, t, url } = await ui();
16
+ const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
17
+ const related = (await site().getEntries("articles", { locale })).filter((r) => r.entry.id !== entry.id).slice(0, 3);
18
+ const index = url(articlesIndexPath(locale));
8
19
  return (
9
- <article data-path={path}>
10
- <h1>{a.title}</h1>
11
- {a.author && <p>By {a.author.name}{a.author.role ? `, ${a.author.role}` : ""}</p>}
12
- {a.excerpt && <p>{a.excerpt}</p>}
13
- </article>
20
+ <>
21
+ <article className="container article">
22
+ <a className="article__back" href={index}>← {t("allArticles")}</a>
23
+ <div className="article__head">
24
+ {a.tags && a.tags.length > 0 && (
25
+ <div className="article__tags">
26
+ {a.tags.map((tag, i) => <span key={`${i}-${tag}`} className={i === 0 ? "tag tag--accent" : "tag"}>{tag}</span>)}
27
+ </div>
28
+ )}
29
+ <h1 className="article__title">{a.title}</h1>
30
+ <p className="article__byline">
31
+ <span className="article__avatar" aria-hidden="true" />
32
+ <span>
33
+ {a.author?.name && <strong>{a.author.name}</strong>}
34
+ {a.author?.name && " · "}
35
+ {a.date && <>{formatDate(a.date, locale)} · </>}
36
+ {t("minRead", { minutes: readingMinutes(a.body) })}
37
+ </span>
38
+ </p>
39
+ </div>
40
+ {a.image?.url && <img className="article__image" src={a.image.url} alt={a.image.alt ?? ""} />}
41
+ <div className="article__body prose prose--lead"><RichText doc={a.body} /></div>
42
+ </article>
43
+ {related.length > 0 && (
44
+ <section className="related">
45
+ <div className="container">
46
+ <div className="related__head">
47
+ <h2 className="h2">{t("relatedArticles")}</h2>
48
+ <a className="link-accent" href={index}>{t("allArticles")} →</a>
49
+ </div>
50
+ <div className="card-grid card-grid--3">
51
+ {related.map(({ entry: r, path }) => {
52
+ const fields = r.fields as ArticleFields;
53
+ return <ArticleCard key={r.id} article={fields} url={url(path ?? articlePath(fields.slug, locale))} locale={locale} compact />;
54
+ })}
55
+ </div>
56
+ </div>
57
+ </section>
58
+ )}
59
+ </>
14
60
  );
15
- });
61
+ }
62
+
63
+ export const ArticlePage = smoodly.entryPage(articles, ArticleView);
@@ -0,0 +1,44 @@
1
+ import { smoodly, z, Zone, type Props } from "smoodly";
2
+ import { PageHero } from "../sections/PageHero";
3
+ import { ArticleCard } from "../ArticleCard";
4
+ import { articlePath, type ArticleFields } from "../articles";
5
+ import { ui } from "../strings";
6
+ import { site } from "@/smoodly/server";
7
+
8
+ // The Articles index: a fixed page whose slugs are the collection's own
9
+ // segments (smoodly/collections.ts), which makes it the collection's
10
+ // index page — the mount in the Pages list opens this page's editor. The
11
+ // editor owns the hero; the grid below is developer JSX that fetches the
12
+ // published articles through the site layer, newest first (the
13
+ // collection's `order`). In the editor canvas the hero is the draft while
14
+ // the grid shows published articles only: the content API is
15
+ // published-only by design.
16
+ export const articlesSchema = smoodly.schema.page({
17
+ name: "articlesIndex",
18
+ title: "Articles",
19
+ slug: "posts",
20
+ zones: {
21
+ hero: z.locked(PageHero),
22
+ },
23
+ meta: { seo: true },
24
+ });
25
+
26
+ async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
27
+ const { locale, url } = await ui();
28
+ const items = await site().getEntries("articles", { locale });
29
+ return (
30
+ <>
31
+ <Zone of={zones.hero} />
32
+ <section className="container articles-index">
33
+ <div className="card-grid">
34
+ {items.map(({ entry, path }) => {
35
+ const a = entry.fields as ArticleFields;
36
+ return <ArticleCard key={entry.id} article={a} url={url(path ?? articlePath(a.slug, locale))} locale={locale} />;
37
+ })}
38
+ </div>
39
+ </section>
40
+ </>
41
+ );
42
+ }
43
+
44
+ export const ArticlesPage = smoodly.page(articlesSchema, ArticlesView);
@@ -1,21 +1,26 @@
1
1
  import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { Hero } from "../sections/Hero";
3
- import { ArticleList } from "../sections/ArticleList";
4
- import { CtaBanner } from "../sections/CtaBanner";
5
- import { Testimonials } from "../sections/Testimonials";
3
+ import { ImageText } from "../sections/ImageText";
4
+ import { Points } from "../sections/Points";
5
+ import { FavouriteArticles } from "../sections/FavouriteArticles";
6
+ import { People } from "../sections/People";
7
+ import { Quote } from "../sections/Quote";
6
8
 
7
9
  // A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
8
10
  // `slug` makes it fixed — one record the editor can neither delete nor
9
11
  // duplicate — and the config's homePageSlug maps that record to "/".
12
+ // The hero is locked: fields only, never added, moved or removed. The
13
+ // body is freeform over five sections. Quote is among them because the
14
+ // picker offers a shared item only where the zone allows its section: an
15
+ // editor may drop a one-off quote, or place the shared one from the
16
+ // picker's Shared group.
10
17
  export const homeSchema = smoodly.schema.page({
11
18
  name: "home",
12
19
  title: "Home",
13
20
  slug: "home",
14
21
  zones: {
15
- hero: z.sections([Hero]).max(1),
16
- body: z.freeform({ sections: [ArticleList, Testimonials] }),
17
- // Locked: fields only. One CtaBanner, always there, never movable.
18
- prefooter: z.locked(CtaBanner),
22
+ hero: z.locked(Hero),
23
+ body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote] }),
19
24
  },
20
25
  meta: { seo: true },
21
26
  });
@@ -25,10 +30,6 @@ function HomeView({ zones }: Props<typeof homeSchema>) {
25
30
  <>
26
31
  <Zone of={zones.hero} />
27
32
  <Zone of={zones.body} />
28
- <Zone of={zones.prefooter} />
29
- <footer>
30
- <p>Hard-coded footer — owned by the developer, invisible to the editor.</p>
31
- </footer>
32
33
  </>
33
34
  );
34
35
  }
@@ -1,19 +1,22 @@
1
1
  import Link from "next/link";
2
2
  import { smoodly, z, Zone, type Props } from "smoodly";
3
- import { Hero } from "../sections/Hero";
4
- import { ArticleList } from "../sections/ArticleList";
5
- import { CtaBanner } from "../sections/CtaBanner";
6
- import { Testimonials } from "../sections/Testimonials";
3
+ import { PageHero } from "../sections/PageHero";
4
+ import { ImageText } from "../sections/ImageText";
5
+ import { Points } from "../sections/Points";
6
+ import { FavouriteArticles } from "../sections/FavouriteArticles";
7
+ import { People } from "../sections/People";
8
+ import { Quote } from "../sections/Quote";
7
9
 
8
10
  // Open registration: editors create as many of these as they like, at
9
11
  // any depth the config allows. `page` is where this one sits in the tree.
12
+ // Its locked hero is the page hero, not the home one: each shape locks
13
+ // its own.
10
14
  export const pageSchema = smoodly.schema.page({
11
15
  name: "page",
12
16
  title: "Page",
13
17
  zones: {
14
- hero: z.sections([Hero]).max(1),
15
- body: z.freeform({ sections: [ArticleList, Testimonials] }),
16
- prefooter: z.locked(CtaBanner),
18
+ hero: z.locked(PageHero),
19
+ body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote] }),
17
20
  },
18
21
  meta: { seo: true },
19
22
  });
@@ -24,7 +27,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
24
27
  {page.ancestors.length > 0 && (
25
28
  // `url` is the ancestor's site URL through the app's href seam
26
29
  // (/fi/yritys under next-intl); `path` would be the bare CMS path.
27
- <nav aria-label="Breadcrumb">
30
+ <nav className="container breadcrumb" aria-label="Breadcrumb">
28
31
  {page.ancestors.map((a) => (
29
32
  <span key={a.path}>{a.hasPage ? <Link href={a.url}>{a.title}</Link> : a.title} / </span>
30
33
  ))}
@@ -33,7 +36,6 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
33
36
  )}
34
37
  <Zone of={zones.hero} />
35
38
  <Zone of={zones.body} />
36
- <Zone of={zones.prefooter} />
37
39
  </>
38
40
  );
39
41
  }