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.
- 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 +17 -9
- 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 -11
- package/templates/next/components/pages/Page.tsx +11 -9
- package/templates/next/components/sections/FavouriteArticles.tsx +42 -0
- package/templates/next/components/sections/Footer.tsx +50 -14
- 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 +9 -2
- package/templates/next/smoodly/sections.ts +13 -4
- package/templates/next/smoodly/server.ts +37 -20
- package/templates/next/smoodly/shared.ts +9 -5
- package/templates/next/supabase/migrations/00000000000000_smoodly.sql +7 -0
- package/templates/next-intl/README.md +19 -3
- package/templates/next-intl/app/[locale]/layout.tsx +24 -12
- 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 -11
- package/templates/next-intl/components/pages/Page.tsx +11 -9
- package/templates/next-intl/components/sections/FavouriteArticles.tsx +42 -0
- package/templates/next-intl/components/sections/Footer.tsx +50 -14
- 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 +9 -2
- package/templates/next-intl/smoodly/sections.ts +13 -4
- package/templates/next-intl/smoodly/server.ts +37 -20
- package/templates/next-intl/smoodly/shared.ts +9 -5
- package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +7 -0
- package/templates/next/components/sections/ArticleList.tsx +0 -31
- package/templates/next/components/sections/CtaBanner.tsx +0 -35
- package/templates/next/components/sections/Testimonials.tsx +0 -30
- package/templates/next-intl/components/sections/ArticleList.tsx +0 -31
- package/templates/next-intl/components/sections/CtaBanner.tsx +0 -35
- package/templates/next-intl/components/sections/Testimonials.tsx +0 -30
|
@@ -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); }
|
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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: { en: "posts", fi: "artikkelit" },
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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.
|
|
16
|
-
body: z.freeform({ sections: [
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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.
|
|
15
|
-
body: z.freeform({ sections: [
|
|
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
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
import { articles } from "@/smoodly/collections";
|
|
3
|
+
import { ArticleCard } from "@/components/ArticleCard";
|
|
4
|
+
import { articlePath } from "@/components/articles";
|
|
5
|
+
import { ui } from "@/components/strings";
|
|
6
|
+
|
|
7
|
+
// Articles an editor picks by hand (refList), as cards. The resolve pass
|
|
8
|
+
// hydrates each ref before render, so the view gets article fields, not
|
|
9
|
+
// ids; an article unpublished after this page went live resolves to
|
|
10
|
+
// null and is skipped.
|
|
11
|
+
export const favouriteArticlesSchema = smoodly.schema.section({
|
|
12
|
+
name: "favouriteArticles",
|
|
13
|
+
title: "Favourite articles",
|
|
14
|
+
description: "Articles picked by hand, as cards.",
|
|
15
|
+
fields: {
|
|
16
|
+
heading: f.text(),
|
|
17
|
+
items: f.refList(() => articles),
|
|
18
|
+
},
|
|
19
|
+
sample: { heading: "From the journal", items: [] },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// async: the cards format dates in the request locale and link through
|
|
23
|
+
// the locale's article segment, both from the strings seam. The canvas
|
|
24
|
+
// is the site's own draft route in an iframe, so an async section
|
|
25
|
+
// previews like any other (DESIGN.md §3 "RSC strategy").
|
|
26
|
+
async function FavouriteArticlesView({ heading, items, styles }: Props<typeof favouriteArticlesSchema>) {
|
|
27
|
+
const { locale, url } = await ui();
|
|
28
|
+
return (
|
|
29
|
+
<smoodly.Section styles={styles} className="favourites">
|
|
30
|
+
<div className="container">
|
|
31
|
+
<h2 className="h2">{heading}</h2>
|
|
32
|
+
<div className="card-grid">
|
|
33
|
+
{(items ?? []).filter(Boolean).map((a, i) => (
|
|
34
|
+
<ArticleCard key={i} article={a} url={url(articlePath(a.slug, locale))} locale={locale} />
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</smoodly.Section>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const FavouriteArticles = smoodly.section(favouriteArticlesSchema, FavouriteArticlesView);
|
|
@@ -1,31 +1,67 @@
|
|
|
1
1
|
import { f, smoodly, type Props } from "smoodly";
|
|
2
2
|
|
|
3
|
-
// The site footer — a VISUAL shared item (smoodly/shared.ts):
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The site footer — a VISUAL shared item (smoodly/shared.ts): one row,
|
|
4
|
+
// edited under Shared content, rendered by the layout. Three link columns
|
|
5
|
+
// as top-level objects, each localized as a whole (an object holding a
|
|
6
|
+
// list — one level, which the list field allows; a list of columns each
|
|
7
|
+
// holding a list would be a nested list, which it refuses). The layout
|
|
8
|
+
// renders FooterView directly to hand it the contact object item, which
|
|
9
|
+
// is not a footer field.
|
|
10
|
+
const column = () =>
|
|
11
|
+
f.object({ title: f.text(), links: f.list(f.object({ label: f.text(), href: f.link() })) }).localized();
|
|
12
|
+
|
|
7
13
|
export const footerSchema = smoodly.schema.section({
|
|
8
14
|
name: "footer",
|
|
9
15
|
title: "Footer",
|
|
10
|
-
description: "The site
|
|
16
|
+
description: "The site footer: tagline, three link columns, copyright.",
|
|
11
17
|
fields: {
|
|
12
18
|
tagline: f.text().localized(),
|
|
19
|
+
company: column(),
|
|
20
|
+
legal: column(),
|
|
21
|
+
social: column(),
|
|
13
22
|
copyright: f.text(),
|
|
14
23
|
},
|
|
15
|
-
styles: {
|
|
16
|
-
tone: f.select(["light", "dark"]).default("dark"),
|
|
17
|
-
},
|
|
18
24
|
sample: {
|
|
19
|
-
tagline: "
|
|
20
|
-
|
|
25
|
+
tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
|
26
|
+
company: { title: "Company", links: [{ label: "About", href: "/company/about-us" }] },
|
|
27
|
+
legal: { title: "Legal", links: [{ label: "Privacy", href: "#" }] },
|
|
28
|
+
social: { title: "Social", links: [{ label: "Instagram", href: "#" }] },
|
|
29
|
+
copyright: "© 2026 Lorem Co. Built with Smoodly.",
|
|
21
30
|
},
|
|
22
31
|
});
|
|
23
32
|
|
|
24
|
-
|
|
33
|
+
export type ContactFields = { email: string; phone?: string; address: string };
|
|
34
|
+
type Column = { title: string; links: { label: string; href: string }[] };
|
|
35
|
+
|
|
36
|
+
function FooterColumn({ column }: { column: Column | undefined }) {
|
|
37
|
+
if (!column) return null;
|
|
38
|
+
return (
|
|
39
|
+
<div className="site-footer__column">
|
|
40
|
+
<span className="site-footer__title">{column.title}</span>
|
|
41
|
+
{(column.links ?? []).map((l, i) => <a key={i} href={l.href}>{l.label}</a>)}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function FooterView({ tagline, company, legal, social, copyright, contact, styles }: Props<typeof footerSchema> & { contact?: ContactFields | null }) {
|
|
25
47
|
return (
|
|
26
|
-
<smoodly.Section as="footer" styles={styles}>
|
|
27
|
-
<
|
|
28
|
-
|
|
48
|
+
<smoodly.Section as="footer" styles={styles} className="site-footer">
|
|
49
|
+
<div className="container site-footer__grid">
|
|
50
|
+
<div className="site-footer__about">
|
|
51
|
+
<p className="site-footer__tagline">{tagline}</p>
|
|
52
|
+
{contact && (
|
|
53
|
+
<address className="site-footer__contact">
|
|
54
|
+
<a href={`mailto:${contact.email}`}>{contact.email}</a>
|
|
55
|
+
{contact.phone && <span>{contact.phone}</span>}
|
|
56
|
+
<span>{contact.address}</span>
|
|
57
|
+
</address>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
<FooterColumn column={company} />
|
|
61
|
+
<FooterColumn column={legal} />
|
|
62
|
+
<FooterColumn column={social} />
|
|
63
|
+
</div>
|
|
64
|
+
<div className="container site-footer__copyright">{copyright}</div>
|
|
29
65
|
</smoodly.Section>
|
|
30
66
|
);
|
|
31
67
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// The site header — a VISUAL shared item (smoodly/shared.ts): one row,
|
|
4
|
+
// edited under Shared content, rendered by the layout, never placed in a
|
|
5
|
+
// page. The brand is the same in every language; the nav is a list of
|
|
6
|
+
// links localized as a whole, so each language has its own labels and
|
|
7
|
+
// hrefs. The layout renders HeaderView directly to hand it the locale
|
|
8
|
+
// links (a paired component takes only its schema's props); the paired
|
|
9
|
+
// Header is what the registry and the admin know.
|
|
10
|
+
export const headerSchema = smoodly.schema.section({
|
|
11
|
+
name: "header",
|
|
12
|
+
title: "Header",
|
|
13
|
+
description: "The site header: brand and navigation.",
|
|
14
|
+
fields: {
|
|
15
|
+
brand: f.text(),
|
|
16
|
+
nav: f.list(f.object({ label: f.text(), href: f.link() })).localized(),
|
|
17
|
+
},
|
|
18
|
+
sample: { brand: "Lorem Co.", nav: [{ label: "Home", href: "/" }] },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type LocaleLink = { code: string; url: string; current: boolean };
|
|
22
|
+
|
|
23
|
+
export function HeaderView({ brand, nav, locales, styles }: Props<typeof headerSchema> & { locales?: LocaleLink[] }) {
|
|
24
|
+
const home = locales?.find((l) => l.current)?.url ?? "/";
|
|
25
|
+
return (
|
|
26
|
+
<smoodly.Section as="header" styles={styles} className="site-header">
|
|
27
|
+
<div className="container site-header__bar">
|
|
28
|
+
<a className="site-header__brand" href={home}>{brand}</a>
|
|
29
|
+
<nav className="site-header__nav">
|
|
30
|
+
{(nav ?? []).map((item, i) => <a key={i} href={item.href}>{item.label}</a>)}
|
|
31
|
+
</nav>
|
|
32
|
+
{locales && locales.length > 1 && (
|
|
33
|
+
<nav className="site-header__locales" aria-label="Language">
|
|
34
|
+
{locales.map((l) => (
|
|
35
|
+
<a key={l.code} href={l.url} aria-current={l.current ? "page" : undefined}>{l.code.toUpperCase()}</a>
|
|
36
|
+
))}
|
|
37
|
+
</nav>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
</smoodly.Section>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Header = smoodly.section(headerSchema, HeaderView);
|