create-smoodly-app 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +22 -2
- package/package.json +1 -1
- package/templates/next/README.md +18 -2
- package/templates/next/app/globals.css +190 -0
- package/templates/next/app/layout.tsx +28 -4
- package/templates/next/components/ArticleCard.tsx +22 -0
- package/templates/next/components/articles.ts +38 -0
- package/templates/next/components/pages/Article.tsx +58 -10
- package/templates/next/components/pages/Articles.tsx +44 -0
- package/templates/next/components/pages/Home.tsx +12 -10
- package/templates/next/components/pages/Page.tsx +11 -8
- package/templates/next/components/sections/FavouriteArticles.tsx +42 -0
- package/templates/next/components/sections/Footer.tsx +69 -0
- package/templates/next/components/sections/Header.tsx +44 -0
- package/templates/next/components/sections/Hero.tsx +32 -7
- package/templates/next/components/sections/ImageText.tsx +59 -0
- package/templates/next/components/sections/PageHero.tsx +38 -0
- package/templates/next/components/sections/People.tsx +36 -0
- package/templates/next/components/sections/Points.tsx +50 -0
- package/templates/next/components/sections/Quote.tsx +33 -0
- package/templates/next/components/strings.ts +23 -0
- package/templates/next/messages/en.json +5 -0
- package/templates/next/package.json +4 -1
- package/templates/next/public/images/about_main.jpg +0 -0
- package/templates/next/public/images/article_photo.jpg +0 -0
- package/templates/next/public/images/hero_image.jpg +0 -0
- package/templates/next/public/images/home_section_2.jpg +0 -0
- package/templates/next/scripts/seed.ts +310 -0
- package/templates/next/smoodly/collections.ts +12 -3
- package/templates/next/smoodly/config.ts +12 -3
- package/templates/next/smoodly/sections.ts +14 -3
- package/templates/next/smoodly/server.ts +39 -21
- package/templates/next/smoodly/shared.ts +26 -0
- package/templates/next/supabase/migrations/00000000000000_smoodly.sql +7 -52
- package/templates/next-intl/README.md +19 -3
- package/templates/next-intl/app/[locale]/layout.tsx +27 -4
- package/templates/next-intl/app/globals.css +190 -0
- package/templates/next-intl/components/ArticleCard.tsx +22 -0
- package/templates/next-intl/components/articles.ts +38 -0
- package/templates/next-intl/components/pages/Article.tsx +58 -10
- package/templates/next-intl/components/pages/Articles.tsx +44 -0
- package/templates/next-intl/components/pages/Home.tsx +12 -10
- package/templates/next-intl/components/pages/Page.tsx +11 -8
- package/templates/next-intl/components/sections/FavouriteArticles.tsx +42 -0
- package/templates/next-intl/components/sections/Footer.tsx +69 -0
- package/templates/next-intl/components/sections/Header.tsx +44 -0
- package/templates/next-intl/components/sections/Hero.tsx +32 -7
- package/templates/next-intl/components/sections/ImageText.tsx +59 -0
- package/templates/next-intl/components/sections/PageHero.tsx +38 -0
- package/templates/next-intl/components/sections/People.tsx +36 -0
- package/templates/next-intl/components/sections/Points.tsx +50 -0
- package/templates/next-intl/components/sections/Quote.tsx +33 -0
- package/templates/next-intl/components/strings.ts +23 -0
- package/templates/next-intl/i18n/request.ts +14 -7
- package/templates/next-intl/messages/en.json +5 -0
- package/templates/next-intl/messages/fi.json +5 -0
- package/templates/next-intl/package.json +4 -1
- package/templates/next-intl/public/images/about_main.jpg +0 -0
- package/templates/next-intl/public/images/article_photo.jpg +0 -0
- package/templates/next-intl/public/images/hero_image.jpg +0 -0
- package/templates/next-intl/public/images/home_section_2.jpg +0 -0
- package/templates/next-intl/scripts/seed.ts +310 -0
- package/templates/next-intl/smoodly/collections.ts +13 -4
- package/templates/next-intl/smoodly/config.ts +12 -3
- package/templates/next-intl/smoodly/sections.ts +14 -3
- package/templates/next-intl/smoodly/server.ts +39 -21
- package/templates/next-intl/smoodly/shared.ts +26 -0
- package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +7 -52
- package/templates/next/components/sections/ArticleList.tsx +0 -31
- package/templates/next/components/sections/CtaBanner.tsx +0 -35
- package/templates/next-intl/components/sections/ArticleList.tsx +0 -31
- package/templates/next-intl/components/sections/CtaBanner.tsx +0 -35
|
@@ -0,0 +1,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);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
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
|
+
|
|
13
|
+
export const footerSchema = smoodly.schema.section({
|
|
14
|
+
name: "footer",
|
|
15
|
+
title: "Footer",
|
|
16
|
+
description: "The site footer: tagline, three link columns, copyright.",
|
|
17
|
+
fields: {
|
|
18
|
+
tagline: f.text().localized(),
|
|
19
|
+
company: column(),
|
|
20
|
+
legal: column(),
|
|
21
|
+
social: column(),
|
|
22
|
+
copyright: f.text(),
|
|
23
|
+
},
|
|
24
|
+
sample: {
|
|
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.",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
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 }) {
|
|
47
|
+
return (
|
|
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>
|
|
65
|
+
</smoodly.Section>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const Footer = smoodly.section(footerSchema, FooterView);
|
|
@@ -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);
|
|
@@ -1,22 +1,47 @@
|
|
|
1
1
|
import { f, smoodly, type Props } from "smoodly";
|
|
2
2
|
|
|
3
|
+
// The home page opener: kicker, heading, text, up to two buttons and a
|
|
4
|
+
// portrait photo. Home locks it (`z.locked(Hero)`): one node an editor
|
|
5
|
+
// fills but cannot add, move or remove. `sample` is what the editor
|
|
6
|
+
// materializes into an empty locked zone, so it is a complete value.
|
|
3
7
|
export const heroSchema = smoodly.schema.section({
|
|
4
8
|
name: "hero",
|
|
5
9
|
title: "Hero",
|
|
10
|
+
description: "The home page opener: heading, text, buttons and a photo.",
|
|
6
11
|
fields: {
|
|
7
|
-
heading: f.text(),
|
|
8
12
|
kicker: f.text().optional(),
|
|
13
|
+
heading: f.text(),
|
|
14
|
+
text: f.text(),
|
|
15
|
+
buttons: f.list(f.object({ label: f.text(), href: f.link() })).max(2),
|
|
16
|
+
image: f.image(),
|
|
9
17
|
},
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
sample: {
|
|
19
|
+
kicker: "Lorem ipsum dolor",
|
|
20
|
+
heading: "Consectetur adipiscing elit sed do eiusmod",
|
|
21
|
+
text: "Tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.",
|
|
22
|
+
buttons: [{ label: "About us", href: "/company/about-us" }],
|
|
23
|
+
image: { url: "/images/hero_image.jpg", alt: "Office interior" },
|
|
12
24
|
},
|
|
13
25
|
});
|
|
14
26
|
|
|
15
|
-
function HeroView({ heading,
|
|
27
|
+
function HeroView({ kicker, heading, text, buttons, image, styles }: Props<typeof heroSchema>) {
|
|
16
28
|
return (
|
|
17
|
-
<smoodly.Section styles={styles}>
|
|
18
|
-
|
|
19
|
-
|
|
29
|
+
<smoodly.Section styles={styles} className="hero">
|
|
30
|
+
<div className="container hero__grid">
|
|
31
|
+
<div className="hero__copy">
|
|
32
|
+
{kicker && <p className="kicker">{kicker}</p>}
|
|
33
|
+
<h1 className="hero__heading">{heading}</h1>
|
|
34
|
+
<p className="hero__text">{text}</p>
|
|
35
|
+
{buttons?.length > 0 && (
|
|
36
|
+
<div className="hero__buttons">
|
|
37
|
+
{buttons.map((b, i) => (
|
|
38
|
+
<a key={i} className={i === 0 ? "button" : "button button--outline"} href={b.href}>{b.label}</a>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
{image?.url && <img className="hero__image" src={image.url} alt={image.alt ?? ""} />}
|
|
44
|
+
</div>
|
|
20
45
|
</smoodly.Section>
|
|
21
46
|
);
|
|
22
47
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { f, RichText, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// A photo beside a heading and richtext, the photo on either side (a
|
|
4
|
+
// style, not a field: styles are never translated). Without a photo the
|
|
5
|
+
// heading takes the left column and the text the right — the About
|
|
6
|
+
// page's text block. The richtext renders through the package's
|
|
7
|
+
// RichText: paragraphs, headings, lists, quotes.
|
|
8
|
+
export const imageTextSchema = smoodly.schema.section({
|
|
9
|
+
name: "imageText",
|
|
10
|
+
title: "Image and text",
|
|
11
|
+
description: "A photo beside a heading and text; text alone in two columns without one.",
|
|
12
|
+
fields: {
|
|
13
|
+
image: f.image().optional(),
|
|
14
|
+
heading: f.text(),
|
|
15
|
+
text: f.richtext(),
|
|
16
|
+
link: f.object({ label: f.text(), href: f.link() }).optional(),
|
|
17
|
+
},
|
|
18
|
+
styles: {
|
|
19
|
+
imageSide: f.select(["left", "right"]).default("left"),
|
|
20
|
+
},
|
|
21
|
+
sample: {
|
|
22
|
+
heading: "Duis aute irure dolor in reprehenderit",
|
|
23
|
+
text: {
|
|
24
|
+
type: "doc",
|
|
25
|
+
content: [{ type: "paragraph", content: [{ type: "text", text: "In voluptate velit esse cillum dolore eu fugiat nulla pariatur." }] }],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function ImageTextView({ image, heading, text, link, styles }: Props<typeof imageTextSchema>) {
|
|
31
|
+
const body = (
|
|
32
|
+
<>
|
|
33
|
+
<div className="prose"><RichText doc={text} /></div>
|
|
34
|
+
{link?.href && <a className="link-accent" href={link.href}>{link.label}</a>}
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
return (
|
|
38
|
+
<smoodly.Section styles={styles} className="image-text">
|
|
39
|
+
<div className="container image-text__grid">
|
|
40
|
+
{image?.url ? (
|
|
41
|
+
<>
|
|
42
|
+
<img className="image-text__image" src={image.url} alt={image.alt ?? ""} />
|
|
43
|
+
<div className="image-text__copy">
|
|
44
|
+
<h2 className="h2">{heading}</h2>
|
|
45
|
+
{body}
|
|
46
|
+
</div>
|
|
47
|
+
</>
|
|
48
|
+
) : (
|
|
49
|
+
<>
|
|
50
|
+
<h2 className="h2">{heading}</h2>
|
|
51
|
+
<div className="image-text__copy">{body}</div>
|
|
52
|
+
</>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</smoodly.Section>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const ImageText = smoodly.section(imageTextSchema, ImageTextView);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// The opener of every page that is not the home page: kicker, heading,
|
|
4
|
+
// text and an optional wide photo. Page and Articles lock it — each page
|
|
5
|
+
// shape locks its own hero, which is the point: a locked zone is per
|
|
6
|
+
// shape. Without an image the copy stands alone (the Articles index).
|
|
7
|
+
export const pageHeroSchema = smoodly.schema.section({
|
|
8
|
+
name: "pageHero",
|
|
9
|
+
title: "Page hero",
|
|
10
|
+
description: "A page opener: kicker, heading, text and an optional wide photo.",
|
|
11
|
+
fields: {
|
|
12
|
+
kicker: f.text().optional(),
|
|
13
|
+
heading: f.text(),
|
|
14
|
+
text: f.text(),
|
|
15
|
+
image: f.image().optional(),
|
|
16
|
+
},
|
|
17
|
+
sample: {
|
|
18
|
+
heading: "About us",
|
|
19
|
+
text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function PageHeroView({ kicker, heading, text, image, styles }: Props<typeof pageHeroSchema>) {
|
|
24
|
+
return (
|
|
25
|
+
<smoodly.Section styles={styles} className="page-hero">
|
|
26
|
+
<div className="container page-hero__inner">
|
|
27
|
+
<div className="page-hero__copy">
|
|
28
|
+
{kicker && <p className="kicker">{kicker}</p>}
|
|
29
|
+
<h1 className="page-hero__heading">{heading}</h1>
|
|
30
|
+
<p className="page-hero__text">{text}</p>
|
|
31
|
+
</div>
|
|
32
|
+
{image?.url && <img className="page-hero__image" src={image.url} alt={image.alt ?? ""} />}
|
|
33
|
+
</div>
|
|
34
|
+
</smoodly.Section>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const PageHero = smoodly.section(pageHeroSchema, PageHeroView);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
import { people } from "@/smoodly/collections";
|
|
3
|
+
|
|
4
|
+
// People picked by hand (refList), four to a row: name and role, the
|
|
5
|
+
// role in the page's language because it is a localized field.
|
|
6
|
+
export const peopleSchema = smoodly.schema.section({
|
|
7
|
+
name: "peopleSection",
|
|
8
|
+
title: "People",
|
|
9
|
+
description: "A heading and a row of people.",
|
|
10
|
+
fields: {
|
|
11
|
+
heading: f.text(),
|
|
12
|
+
people: f.refList(() => people),
|
|
13
|
+
},
|
|
14
|
+
sample: { heading: "Our people", people: [] },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function PeopleView({ heading, people: persons, styles }: Props<typeof peopleSchema>) {
|
|
18
|
+
return (
|
|
19
|
+
<smoodly.Section styles={styles} className="people">
|
|
20
|
+
<div className="container">
|
|
21
|
+
<h2 className="h2">{heading}</h2>
|
|
22
|
+
<div className="people__grid">
|
|
23
|
+
{(persons ?? []).filter(Boolean).map((person, i) => (
|
|
24
|
+
<div key={i} className="person">
|
|
25
|
+
<span className="person__mark" aria-hidden="true">{person.name?.charAt(0)}</span>
|
|
26
|
+
<span className="person__name">{person.name}</span>
|
|
27
|
+
<span className="person__role">{person.role}</span>
|
|
28
|
+
</div>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</smoodly.Section>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const People = smoodly.section(peopleSchema, PeopleView);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// A heading over one to six points, each an icon, a heading and a line
|
|
4
|
+
// of text: the list field with min/max, and an object inside the list.
|
|
5
|
+
export const pointsSchema = smoodly.schema.section({
|
|
6
|
+
name: "points",
|
|
7
|
+
title: "Points",
|
|
8
|
+
description: "A heading and one to six points with an icon each.",
|
|
9
|
+
fields: {
|
|
10
|
+
heading: f.text(),
|
|
11
|
+
points: f.list(f.object({
|
|
12
|
+
icon: f.select(["circle", "square", "diamond"]),
|
|
13
|
+
heading: f.text(),
|
|
14
|
+
text: f.text(),
|
|
15
|
+
})).min(1).max(6),
|
|
16
|
+
},
|
|
17
|
+
sample: {
|
|
18
|
+
heading: "Quis autem vel eum",
|
|
19
|
+
points: [{ icon: "circle", heading: "Iure reprehenderit", text: "Qui in ea voluptate velit esse quam nihil molestiae consequatur." }],
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const ICONS = {
|
|
24
|
+
circle: <circle cx="20" cy="20" r="18" />,
|
|
25
|
+
square: <rect x="3" y="3" width="34" height="34" rx="4" />,
|
|
26
|
+
diamond: <polygon points="20,2 38,20 20,38 2,20" />,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function PointsView({ heading, points, styles }: Props<typeof pointsSchema>) {
|
|
30
|
+
return (
|
|
31
|
+
<smoodly.Section styles={styles} className="points">
|
|
32
|
+
<div className="container">
|
|
33
|
+
<h2 className="h2">{heading}</h2>
|
|
34
|
+
<div className="points__grid">
|
|
35
|
+
{(points ?? []).map((point, i) => (
|
|
36
|
+
<div key={i} className="point">
|
|
37
|
+
<svg className="point__icon" width="40" height="40" viewBox="0 0 40 40" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
38
|
+
{ICONS[point.icon] ?? ICONS.circle}
|
|
39
|
+
</svg>
|
|
40
|
+
<h3 className="point__heading">{point.heading}</h3>
|
|
41
|
+
<p className="point__text">{point.text}</p>
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</smoodly.Section>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const Points = smoodly.section(pointsSchema, PointsView);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { f, smoodly, type Props } from "smoodly";
|
|
2
|
+
|
|
3
|
+
// A pull quote. Registered as a shared item (smoodly/shared.ts): one row
|
|
4
|
+
// edited under Shared content, placed on pages from the "+" picker's
|
|
5
|
+
// Shared group as a linked node. The body zones also list it, because
|
|
6
|
+
// the picker offers a shared item only where the zone allows its
|
|
7
|
+
// section — so an editor may also drop a one-off quote.
|
|
8
|
+
export const quoteSchema = smoodly.schema.section({
|
|
9
|
+
name: "quote",
|
|
10
|
+
title: "Quote",
|
|
11
|
+
description: "A pull quote with its attribution.",
|
|
12
|
+
fields: {
|
|
13
|
+
text: f.text().localized(),
|
|
14
|
+
attribution: f.text().localized(),
|
|
15
|
+
},
|
|
16
|
+
sample: {
|
|
17
|
+
text: "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit.",
|
|
18
|
+
attribution: "Jane Doe, Founder",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function QuoteView({ text, attribution, styles }: Props<typeof quoteSchema>) {
|
|
23
|
+
return (
|
|
24
|
+
<smoodly.Section styles={styles} className="quote">
|
|
25
|
+
<div className="quote__inner">
|
|
26
|
+
<blockquote className="quote__text">“{text}”</blockquote>
|
|
27
|
+
<p className="quote__attribution">{attribution}</p>
|
|
28
|
+
</div>
|
|
29
|
+
</smoodly.Section>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const Quote = smoodly.section(quoteSchema, QuoteView);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The one seam for the site's own UI strings and the request locale —
|
|
2
|
+
// what a view says that is not content: "All articles", "min read",
|
|
3
|
+
// "Related articles". A single-language site has one locale and one
|
|
4
|
+
// message map, so no i18n library sits behind this: it reads
|
|
5
|
+
// messages/en.json directly, and `url` is the identity because there is
|
|
6
|
+
// no locale prefix to add. (The multilingual scaffold's copy reads
|
|
7
|
+
// next-intl instead.)
|
|
8
|
+
import en from "@/messages/en.json";
|
|
9
|
+
|
|
10
|
+
export type StringKey = keyof typeof en;
|
|
11
|
+
export type UI = {
|
|
12
|
+
locale: string;
|
|
13
|
+
t: (key: StringKey, values?: Record<string, string | number>) => string;
|
|
14
|
+
url: (path: string) => string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function ui(): Promise<UI> {
|
|
18
|
+
return {
|
|
19
|
+
locale: "en",
|
|
20
|
+
t: (key, values = {}) => en[key].replace(/\{(\w+)\}/g, (_, name: string) => String(values[name] ?? "")),
|
|
21
|
+
url: (path) => path,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"dev": "next dev",
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
|
+
"seed": "tsx scripts/seed.ts",
|
|
9
10
|
"typecheck": "tsc --noEmit"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
@@ -13,12 +14,14 @@
|
|
|
13
14
|
"next": "^16.3.4",
|
|
14
15
|
"react": "^19",
|
|
15
16
|
"react-dom": "^19",
|
|
16
|
-
"smoodly": "0.0.
|
|
17
|
+
"smoodly": "0.0.9"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
20
|
+
"@next/env": "^16.3.4",
|
|
19
21
|
"@types/node": "^26.4.1",
|
|
20
22
|
"@types/react": "^19",
|
|
21
23
|
"@types/react-dom": "^19.2.5",
|
|
24
|
+
"tsx": "^4",
|
|
22
25
|
"typescript": "^5"
|
|
23
26
|
}
|
|
24
27
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|