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
|
@@ -9,10 +9,13 @@ mounted at `/admin` from the `smoodly` package.
|
|
|
9
9
|
|
|
10
10
|
- `app/[locale]/page.tsx` — the home page: one line calling renderSmoodlyPath([], { locale }).
|
|
11
11
|
- `app/[locale]/[...path]/page.tsx` — every other page and every collection entry, at any depth, with translated segments per locale.
|
|
12
|
-
- `app/[locale]/layout.tsx` — the site's root layout
|
|
12
|
+
- `app/[locale]/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the header and footer from shared content, the FI / EN links; the admin has its own under `app/(smoodly)/admin/`.
|
|
13
13
|
- `i18n/routing.ts` — the locales and the default; `smoodly/config.ts` reads them from here. `i18n/request.ts` and `i18n/navigation.ts` are next-intl's stock files; `proxy.ts` is its locale routing (`/admin` excluded).
|
|
14
|
-
- `components/pages/` — the page shapes and entry
|
|
15
|
-
- `components/sections/` — the building blocks editors compose with. Add one, list it in `smoodly/sections.ts`.
|
|
14
|
+
- `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).
|
|
15
|
+
- `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`.
|
|
16
|
+
- `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
|
|
17
|
+
- `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
|
|
18
|
+
- `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
|
|
16
19
|
- `smoodly/collections.ts` — structured content (people, articles). Add a collection, then add its view to a new migration with `collectionSQL` from `smoodly`.
|
|
17
20
|
- `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).
|
|
18
21
|
- `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
|
|
@@ -23,6 +26,19 @@ admin's page settings; a collection's base segment per locale is its
|
|
|
23
26
|
next-intl's messages — add `messages` in `i18n/request.ts`:
|
|
24
27
|
https://next-intl.dev/docs/usage/configuration
|
|
25
28
|
|
|
29
|
+
## What is in the starter
|
|
30
|
+
|
|
31
|
+
The Lorem Co. site the installer seeded is a tour of the features:
|
|
32
|
+
|
|
33
|
+
- **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).
|
|
34
|
+
- **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.
|
|
35
|
+
- **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
|
|
36
|
+
- **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.
|
|
37
|
+
- **Shared content** — the header and footer the layout renders, the contact details the footer reads, the quote pages place.
|
|
38
|
+
|
|
39
|
+
Every page and entry exists in each locale with its own slug. Reseed an
|
|
40
|
+
empty database any time with `npm run seed`.
|
|
41
|
+
|
|
26
42
|
## Editors
|
|
27
43
|
|
|
28
44
|
Anyone who may open `/admin` is a Supabase Auth user with a row in the
|
|
@@ -1,20 +1,43 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { notFound } from "next/navigation";
|
|
3
|
+
import { Instrument_Sans, Newsreader } from "next/font/google";
|
|
3
4
|
import { hasLocale, NextIntlClientProvider } from "next-intl";
|
|
4
5
|
import { routing } from "@/i18n/routing";
|
|
6
|
+
import { HeaderView } from "@/components/sections/Header";
|
|
7
|
+
import { FooterView } from "@/components/sections/Footer";
|
|
8
|
+
import { contact, footer, header } from "@/smoodly/shared";
|
|
9
|
+
import { getSmoodlyShared, href } from "@/smoodly/server";
|
|
10
|
+
import "../globals.css";
|
|
5
11
|
|
|
6
|
-
|
|
12
|
+
// The starter's two faces, as CSS variables globals.css reads.
|
|
13
|
+
const serif = Newsreader({ subsets: ["latin", "latin-ext"], style: ["normal", "italic"], variable: "--font-serif" });
|
|
14
|
+
const sans = Instrument_Sans({ subsets: ["latin", "latin-ext"], weight: ["400", "500", "600"], variable: "--font-sans" });
|
|
15
|
+
|
|
16
|
+
export const metadata = { title: "Lorem Co." };
|
|
7
17
|
|
|
8
18
|
// The site's root layout. The admin has its own under app/(smoodly)/admin —
|
|
9
19
|
// two root layouts, a full page load between them, which is fine: they
|
|
10
|
-
// are separate apps in spirit.
|
|
20
|
+
// are separate apps in spirit. Header, footer and the contact block are
|
|
21
|
+
// shared content: fetched here, cached per locale under their tag, null
|
|
22
|
+
// until published in this locale. The header also gets the locale links
|
|
23
|
+
// — the visitor's only language switch, since locale detection is off.
|
|
11
24
|
export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
|
|
12
25
|
const { locale } = await params;
|
|
13
26
|
if (!hasLocale(routing.locales, locale)) notFound();
|
|
27
|
+
const [headerProps, footerProps, contactFields] = await Promise.all([
|
|
28
|
+
getSmoodlyShared(header, { locale }),
|
|
29
|
+
getSmoodlyShared(footer, { locale }),
|
|
30
|
+
getSmoodlyShared(contact, { locale }),
|
|
31
|
+
]);
|
|
32
|
+
const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
|
|
14
33
|
return (
|
|
15
|
-
<html lang={locale}>
|
|
34
|
+
<html lang={locale} className={`${serif.variable} ${sans.variable}`}>
|
|
16
35
|
<body>
|
|
17
|
-
<NextIntlClientProvider>
|
|
36
|
+
<NextIntlClientProvider>
|
|
37
|
+
{headerProps && <HeaderView {...headerProps} locales={locales} />}
|
|
38
|
+
<main>{children}</main>
|
|
39
|
+
{footerProps && <FooterView {...footerProps} contact={contactFields} />}
|
|
40
|
+
</NextIntlClientProvider>
|
|
18
41
|
</body>
|
|
19
42
|
</html>
|
|
20
43
|
);
|
|
@@ -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,20 +1,26 @@
|
|
|
1
1
|
import { smoodly, z, Zone, type Props } from "smoodly";
|
|
2
2
|
import { Hero } from "../sections/Hero";
|
|
3
|
-
import {
|
|
4
|
-
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";
|
|
5
8
|
|
|
6
9
|
// A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
|
|
7
10
|
// `slug` makes it fixed — one record the editor can neither delete nor
|
|
8
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.
|
|
9
17
|
export const homeSchema = smoodly.schema.page({
|
|
10
18
|
name: "home",
|
|
11
19
|
title: "Home",
|
|
12
20
|
slug: "home",
|
|
13
21
|
zones: {
|
|
14
|
-
hero: z.
|
|
15
|
-
body: z.freeform({ sections: [
|
|
16
|
-
// Locked: fields only. One CtaBanner, always there, never movable.
|
|
17
|
-
prefooter: z.locked(CtaBanner),
|
|
22
|
+
hero: z.locked(Hero),
|
|
23
|
+
body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote] }),
|
|
18
24
|
},
|
|
19
25
|
meta: { seo: true },
|
|
20
26
|
});
|
|
@@ -24,10 +30,6 @@ function HomeView({ zones }: Props<typeof homeSchema>) {
|
|
|
24
30
|
<>
|
|
25
31
|
<Zone of={zones.hero} />
|
|
26
32
|
<Zone of={zones.body} />
|
|
27
|
-
<Zone of={zones.prefooter} />
|
|
28
|
-
<footer>
|
|
29
|
-
<p>Hard-coded footer — owned by the developer, invisible to the editor.</p>
|
|
30
|
-
</footer>
|
|
31
33
|
</>
|
|
32
34
|
);
|
|
33
35
|
}
|
|
@@ -1,18 +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 {
|
|
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";
|
|
6
9
|
|
|
7
10
|
// Open registration: editors create as many of these as they like, at
|
|
8
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.
|
|
9
14
|
export const pageSchema = smoodly.schema.page({
|
|
10
15
|
name: "page",
|
|
11
16
|
title: "Page",
|
|
12
17
|
zones: {
|
|
13
|
-
hero: z.
|
|
14
|
-
body: z.freeform({ sections: [
|
|
15
|
-
prefooter: z.locked(CtaBanner),
|
|
18
|
+
hero: z.locked(PageHero),
|
|
19
|
+
body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote] }),
|
|
16
20
|
},
|
|
17
21
|
meta: { seo: true },
|
|
18
22
|
});
|
|
@@ -23,7 +27,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
|
23
27
|
{page.ancestors.length > 0 && (
|
|
24
28
|
// `url` is the ancestor's site URL through the app's href seam
|
|
25
29
|
// (/fi/yritys under next-intl); `path` would be the bare CMS path.
|
|
26
|
-
<nav aria-label="Breadcrumb">
|
|
30
|
+
<nav className="container breadcrumb" aria-label="Breadcrumb">
|
|
27
31
|
{page.ancestors.map((a) => (
|
|
28
32
|
<span key={a.path}>{a.hasPage ? <Link href={a.url}>{a.title}</Link> : a.title} / </span>
|
|
29
33
|
))}
|
|
@@ -32,7 +36,6 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
|
|
|
32
36
|
)}
|
|
33
37
|
<Zone of={zones.hero} />
|
|
34
38
|
<Zone of={zones.body} />
|
|
35
|
-
<Zone of={zones.prefooter} />
|
|
36
39
|
</>
|
|
37
40
|
);
|
|
38
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);
|