@jimmy_harika/websitekit 0.3.1
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/README.md +86 -0
- package/package.json +54 -0
- package/src/blocks/author-bio.tsx +52 -0
- package/src/blocks/author-hero.tsx +99 -0
- package/src/blocks/book-grid.tsx +97 -0
- package/src/blocks/book-spotlight.tsx +109 -0
- package/src/blocks/contact.tsx +64 -0
- package/src/blocks/faq.tsx +65 -0
- package/src/blocks/form.tsx +234 -0
- package/src/blocks/gallery.tsx +230 -0
- package/src/blocks/index.ts +32 -0
- package/src/blocks/newsletter.tsx +100 -0
- package/src/blocks/pricing.tsx +120 -0
- package/src/blocks/primitives.tsx +109 -0
- package/src/blocks/testimonials.tsx +89 -0
- package/src/catalogs/author/catalog.tsx +496 -0
- package/src/catalogs/author/index.ts +9 -0
- package/src/catalogs/author/templates.ts +133 -0
- package/src/catalogs/publisher/catalog.tsx +308 -0
- package/src/catalogs/publisher/index.ts +10 -0
- package/src/catalogs/publisher/templates.ts +158 -0
- package/src/editable.tsx +76 -0
- package/src/editor/BlockLibrary.tsx +110 -0
- package/src/editor/Canvas.tsx +128 -0
- package/src/editor/ErrorBoundary.tsx +54 -0
- package/src/editor/InsertPoint.tsx +31 -0
- package/src/editor/Inspector.tsx +341 -0
- package/src/editor/PageBuilder.tsx +217 -0
- package/src/editor/SectionFrame.tsx +127 -0
- package/src/editor/SectionsPanel.tsx +149 -0
- package/src/editor/TemplateGallery.tsx +98 -0
- package/src/editor/ThemePanel.tsx +131 -0
- package/src/editor/Toolbar.tsx +162 -0
- package/src/editor/dnd.tsx +61 -0
- package/src/editor/edit-primitives.tsx +164 -0
- package/src/editor/index.tsx +8 -0
- package/src/editor/shortcuts.ts +106 -0
- package/src/editor/ui-context.tsx +32 -0
- package/src/index.ts +11 -0
- package/src/lib/cn.ts +8 -0
- package/src/model.ts +116 -0
- package/src/registry.ts +138 -0
- package/src/renderer/index.tsx +55 -0
- package/src/store.ts +216 -0
- package/src/theme.ts +85 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @jimmy_harika/websitekit
|
|
2
|
+
|
|
3
|
+
The Snow Labs website builder — **one shared, block-agnostic, curated-section
|
|
4
|
+
engine** used by every Snow Labs app. Build it once here, and every app
|
|
5
|
+
(photographer sites, author sites, and more) inherits it. New blocks and
|
|
6
|
+
verticals land here and upgrade all consumers at once.
|
|
7
|
+
|
|
8
|
+
This is the **single home** for the builder: the engine, a design-system-agnostic
|
|
9
|
+
block library, and per-industry catalogs all live in this repo and ship as one
|
|
10
|
+
private package.
|
|
11
|
+
|
|
12
|
+
## Why custom (not Puck / GrapesJS)
|
|
13
|
+
|
|
14
|
+
The engine is the product. Curated sections (not freeform grids) keep sites
|
|
15
|
+
beautiful-by-default for non-designers, and the **same component renders in the
|
|
16
|
+
editor canvas and in production** (an injected `edit` prop makes fields inline-
|
|
17
|
+
editable; production omits it for a byte-identical static render) — an RSC + CDN
|
|
18
|
+
perf moat heavy runtimes can't match.
|
|
19
|
+
|
|
20
|
+
## Layers
|
|
21
|
+
|
|
22
|
+
| Export | What |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `@jimmy_harika/websitekit` | Core model · registry · theme (RSC-safe) |
|
|
25
|
+
| `@jimmy_harika/websitekit/editor` | `<PageBuilder>` client editor |
|
|
26
|
+
| `@jimmy_harika/websitekit/renderer` | `PageRenderer` / `PageFonts` (RSC-safe production render) |
|
|
27
|
+
| `@jimmy_harika/websitekit/store` | zustand + zundo store |
|
|
28
|
+
| `@jimmy_harika/websitekit/blocks` | Design-system-agnostic blocks — render off `--pb-*` theme tokens, no app-UI imports (this is what makes a block portable to every app) |
|
|
29
|
+
| `@jimmy_harika/websitekit/catalogs/author` | Author-website catalog + templates |
|
|
30
|
+
|
|
31
|
+
## The app contract
|
|
32
|
+
|
|
33
|
+
A consuming app supplies only:
|
|
34
|
+
|
|
35
|
+
1. **A catalog** (or use a shipped one from `./catalogs/*`).
|
|
36
|
+
2. **A persistence adapter** — load → `PageDocument`, save, publish.
|
|
37
|
+
3. **`uploadImage(file) => Promise<url>`**.
|
|
38
|
+
4. **A data-source resolver** — pre-resolve "live" block props (e.g. an author's
|
|
39
|
+
real books) from the app's own backend before render. App-level transform; no
|
|
40
|
+
engine change. Keeps blocks presentational and portable.
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import { PageBuilder } from "@jimmy_harika/websitekit/editor";
|
|
44
|
+
import { AUTHOR_CATALOG, AUTHOR_TEMPLATES } from "@jimmy_harika/websitekit/catalogs/author";
|
|
45
|
+
|
|
46
|
+
<PageBuilder
|
|
47
|
+
catalog={AUTHOR_CATALOG}
|
|
48
|
+
templates={AUTHOR_TEMPLATES}
|
|
49
|
+
initialDocument={doc}
|
|
50
|
+
onChange={autosave}
|
|
51
|
+
onPublish={publish}
|
|
52
|
+
uploadImage={upload}
|
|
53
|
+
/>;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
// production (RSC)
|
|
58
|
+
import { PageRenderer, PageFonts } from "@jimmy_harika/websitekit/renderer";
|
|
59
|
+
import { AUTHOR_CATALOG } from "@jimmy_harika/websitekit/catalogs/author";
|
|
60
|
+
|
|
61
|
+
<>
|
|
62
|
+
<PageFonts doc={doc} />
|
|
63
|
+
<PageRenderer doc={doc} catalog={AUTHOR_CATALOG} />
|
|
64
|
+
</>;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Consuming app notes
|
|
68
|
+
|
|
69
|
+
- The package ships **raw TS/TSX** — add `@jimmy_harika/websitekit` to your Next
|
|
70
|
+
`transpilePackages`, and ensure a **single React copy** (dedupe via your
|
|
71
|
+
bundler's resolve alias).
|
|
72
|
+
- `zod` is a peer (v3 or v4). Catalogs authored here use the repo's zod; apps on
|
|
73
|
+
a different major only need the `obj()` cast bridge if they author their own
|
|
74
|
+
catalog.
|
|
75
|
+
- The **editor chrome** (`@jimmy_harika/websitekit/editor`) is styled with shadcn/ui
|
|
76
|
+
semantic utilities and reads the host's shadcn theme. If your app doesn't use
|
|
77
|
+
shadcn/ui, see **[PREREQUISITES.md](./PREREQUISITES.md)** for the required CSS
|
|
78
|
+
variables. Rendered sites and blocks are portable and do not depend on shadcn.
|
|
79
|
+
|
|
80
|
+
## Roadmap
|
|
81
|
+
|
|
82
|
+
- **Catalogs:** author (shipped) → photographer (migrate from pixbox) → driving
|
|
83
|
+
schools, etc. Each vertical is additive.
|
|
84
|
+
- **Backend:** a companion Convex component (isolated tables + app-facing API) so
|
|
85
|
+
persistence is shared infra too.
|
|
86
|
+
- **Edge:** Cloudflare-for-SaaS custom domains + wildcard subdomain serving.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jimmy_harika/websitekit",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Snow Labs website builder engine. Block-agnostic, curated-section. One shared builder across every Snow Labs app \u2014 engine + design-system-agnostic blocks + per-industry catalogs (author, photographer, \u2026). Each app plugs in a catalog + theme + persistence adapter.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/jimmyharika/websitekit.git"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.ts",
|
|
21
|
+
"./store": "./src/store.ts",
|
|
22
|
+
"./renderer": "./src/renderer/index.tsx",
|
|
23
|
+
"./editor": "./src/editor/index.tsx",
|
|
24
|
+
"./theme": "./src/theme.ts",
|
|
25
|
+
"./blocks": "./src/blocks/index.ts",
|
|
26
|
+
"./catalogs/author": "./src/catalogs/author/index.ts",
|
|
27
|
+
"./catalogs/publisher": "./src/catalogs/publisher/index.ts",
|
|
28
|
+
"./lib/cn": "./src/lib/cn.ts"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
|
|
35
|
+
"clsx": "^2.1.1",
|
|
36
|
+
"lucide-react": "^0.525.0",
|
|
37
|
+
"tailwind-merge": "^3.4.0",
|
|
38
|
+
"zundo": "^2.3.0",
|
|
39
|
+
"zustand": "^5.0.9"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": "^19.0.0",
|
|
43
|
+
"react-dom": "^19.0.0",
|
|
44
|
+
"zod": "^3.25.0 || ^4.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/react": "^19.2.7",
|
|
48
|
+
"@types/react-dom": "^19.2.3",
|
|
49
|
+
"react": "^19.2.0",
|
|
50
|
+
"react-dom": "^19.2.0",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"zod": "^3.25.76"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** About-the-author — a portrait beside a story column. `flip` swaps sides. */
|
|
2
|
+
import { renderImage, renderText, type EditPrimitives } from "../editable";
|
|
3
|
+
import { BODY, Eyebrow, HEADING, MUTED, RADIUS, Section, TEXT } from "./primitives";
|
|
4
|
+
|
|
5
|
+
export interface AuthorBioProps {
|
|
6
|
+
eyebrow?: string;
|
|
7
|
+
heading: string;
|
|
8
|
+
body: string;
|
|
9
|
+
portrait?: string;
|
|
10
|
+
flip?: boolean;
|
|
11
|
+
edit?: EditPrimitives;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function AuthorBio({ eyebrow, heading, body, portrait, flip = false, edit }: AuthorBioProps) {
|
|
15
|
+
return (
|
|
16
|
+
<Section tint>
|
|
17
|
+
<div className="grid items-center gap-10 md:grid-cols-2 md:gap-16">
|
|
18
|
+
<div
|
|
19
|
+
className={`relative aspect-[4/5] w-full overflow-hidden ${flip ? "md:order-2" : ""}`}
|
|
20
|
+
style={{ borderRadius: RADIUS, background: MUTED }}
|
|
21
|
+
>
|
|
22
|
+
{renderImage(edit, {
|
|
23
|
+
field: "portrait",
|
|
24
|
+
src: portrait,
|
|
25
|
+
alt: heading,
|
|
26
|
+
className: "h-full w-full object-cover",
|
|
27
|
+
})}
|
|
28
|
+
</div>
|
|
29
|
+
<div className={flip ? "md:order-1" : ""}>
|
|
30
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
31
|
+
{renderText(edit, {
|
|
32
|
+
field: "heading",
|
|
33
|
+
value: heading,
|
|
34
|
+
as: "h2",
|
|
35
|
+
className: "text-[clamp(1.75rem,4vw,2.75rem)] font-light leading-tight",
|
|
36
|
+
style: { fontFamily: HEADING, color: TEXT },
|
|
37
|
+
placeholder: "About the author",
|
|
38
|
+
})}
|
|
39
|
+
{renderText(edit, {
|
|
40
|
+
field: "body",
|
|
41
|
+
value: body,
|
|
42
|
+
as: "div",
|
|
43
|
+
className: "mt-6 space-y-4 text-[1.0625rem] leading-relaxed whitespace-pre-wrap",
|
|
44
|
+
style: { fontFamily: BODY, color: MUTED },
|
|
45
|
+
placeholder: "Tell readers who you are, what you write, and why it matters.",
|
|
46
|
+
multiline: true,
|
|
47
|
+
})}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</Section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Author hero — name + tagline, over a portrait or as a tall type-only banner. */
|
|
2
|
+
import { renderImage, renderText, type EditPrimitives } from "../editable";
|
|
3
|
+
import { BODY, Eyebrow, HEADING, MUTED, Section, TEXT } from "./primitives";
|
|
4
|
+
|
|
5
|
+
export interface AuthorHeroProps {
|
|
6
|
+
eyebrow?: string;
|
|
7
|
+
name: string;
|
|
8
|
+
tagline?: string;
|
|
9
|
+
portrait?: string;
|
|
10
|
+
align?: "center" | "left";
|
|
11
|
+
edit?: EditPrimitives;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function AuthorHero({ eyebrow, name, tagline, portrait, align = "center", edit }: AuthorHeroProps) {
|
|
15
|
+
// Left layout: split — type beside a tall portrait.
|
|
16
|
+
if (align === "left") {
|
|
17
|
+
return (
|
|
18
|
+
<Section style={{ paddingBlock: "calc(var(--pb-spacing,1) * 5rem)" }}>
|
|
19
|
+
<div className="grid items-center gap-10 md:grid-cols-2 md:gap-16">
|
|
20
|
+
<div>
|
|
21
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
22
|
+
{renderText(edit, {
|
|
23
|
+
field: "name",
|
|
24
|
+
value: name,
|
|
25
|
+
as: "h1",
|
|
26
|
+
className: "text-[clamp(2.5rem,7vw,5rem)] font-light leading-[0.95]",
|
|
27
|
+
style: { fontFamily: HEADING, color: TEXT },
|
|
28
|
+
placeholder: "Author Name",
|
|
29
|
+
})}
|
|
30
|
+
{renderText(edit, {
|
|
31
|
+
field: "tagline",
|
|
32
|
+
value: tagline ?? "",
|
|
33
|
+
as: "p",
|
|
34
|
+
className: "mt-6 max-w-md text-lg leading-relaxed",
|
|
35
|
+
style: { fontFamily: BODY, color: MUTED },
|
|
36
|
+
placeholder: "A one-line tagline about your work.",
|
|
37
|
+
multiline: true,
|
|
38
|
+
})}
|
|
39
|
+
</div>
|
|
40
|
+
<div
|
|
41
|
+
className="relative aspect-[4/5] w-full overflow-hidden"
|
|
42
|
+
style={{ borderRadius: "var(--pb-radius, 4px)", background: MUTED }}
|
|
43
|
+
>
|
|
44
|
+
{renderImage(edit, {
|
|
45
|
+
field: "portrait",
|
|
46
|
+
src: portrait,
|
|
47
|
+
alt: name,
|
|
48
|
+
priority: true,
|
|
49
|
+
className: "h-full w-full object-cover",
|
|
50
|
+
})}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</Section>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Center layout: full-bleed portrait with overlay, or tall type-only banner.
|
|
58
|
+
return (
|
|
59
|
+
<section className="relative flex min-h-[78vh] w-full items-center justify-center overflow-hidden px-6 text-center">
|
|
60
|
+
{portrait ? (
|
|
61
|
+
<>
|
|
62
|
+
<div className="absolute inset-0">
|
|
63
|
+
{renderImage(edit, {
|
|
64
|
+
field: "portrait",
|
|
65
|
+
src: portrait,
|
|
66
|
+
alt: name,
|
|
67
|
+
priority: true,
|
|
68
|
+
className: "h-full w-full object-cover",
|
|
69
|
+
})}
|
|
70
|
+
</div>
|
|
71
|
+
<div
|
|
72
|
+
className="absolute inset-0"
|
|
73
|
+
style={{ background: "linear-gradient(to bottom, rgba(0,0,0,0.35), rgba(0,0,0,0.6))" }}
|
|
74
|
+
/>
|
|
75
|
+
</>
|
|
76
|
+
) : null}
|
|
77
|
+
<div className="relative z-10 mx-auto max-w-3xl">
|
|
78
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
79
|
+
{renderText(edit, {
|
|
80
|
+
field: "name",
|
|
81
|
+
value: name,
|
|
82
|
+
as: "h1",
|
|
83
|
+
className: "text-[clamp(2.75rem,9vw,6.5rem)] font-light leading-[0.95]",
|
|
84
|
+
style: { fontFamily: HEADING, color: portrait ? "#ffffff" : TEXT },
|
|
85
|
+
placeholder: "Author Name",
|
|
86
|
+
})}
|
|
87
|
+
{renderText(edit, {
|
|
88
|
+
field: "tagline",
|
|
89
|
+
value: tagline ?? "",
|
|
90
|
+
as: "p",
|
|
91
|
+
className: "mx-auto mt-6 max-w-xl text-lg leading-relaxed",
|
|
92
|
+
style: { fontFamily: BODY, color: portrait ? "rgba(255,255,255,0.85)" : MUTED },
|
|
93
|
+
placeholder: "A one-line tagline about your work.",
|
|
94
|
+
multiline: true,
|
|
95
|
+
})}
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Book grid — the author's shelf. `items` is presentational; the app resolves
|
|
3
|
+
* the live list (e.g. published books) before render. Columns are responsive.
|
|
4
|
+
*/
|
|
5
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
6
|
+
import {
|
|
7
|
+
BODY,
|
|
8
|
+
type BookItem,
|
|
9
|
+
Eyebrow,
|
|
10
|
+
HAIRLINE,
|
|
11
|
+
HEADING,
|
|
12
|
+
MUTED,
|
|
13
|
+
RADIUS,
|
|
14
|
+
Section,
|
|
15
|
+
TEXT,
|
|
16
|
+
} from "./primitives";
|
|
17
|
+
|
|
18
|
+
export interface BookGridProps {
|
|
19
|
+
eyebrow?: string;
|
|
20
|
+
heading?: string;
|
|
21
|
+
items?: BookItem[];
|
|
22
|
+
/** May arrive as a number or a string from the inspector select. */
|
|
23
|
+
columns?: 2 | 3 | 4 | string;
|
|
24
|
+
edit?: EditPrimitives;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PLACEHOLDER: BookItem[] = [
|
|
28
|
+
{ title: "First Title", subtitle: "Novel" },
|
|
29
|
+
{ title: "Second Title", subtitle: "Novel" },
|
|
30
|
+
{ title: "Third Title", subtitle: "Short Stories" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const COLS: Record<number, string> = {
|
|
34
|
+
2: "grid-cols-2 sm:grid-cols-2",
|
|
35
|
+
3: "grid-cols-2 sm:grid-cols-3",
|
|
36
|
+
4: "grid-cols-2 sm:grid-cols-4",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function BookGrid({ eyebrow, heading, items, columns = 3, edit }: BookGridProps) {
|
|
40
|
+
const books = items && items.length > 0 ? items : PLACEHOLDER;
|
|
41
|
+
return (
|
|
42
|
+
<Section>
|
|
43
|
+
<div className="mb-10 text-center">
|
|
44
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
45
|
+
{renderText(edit, {
|
|
46
|
+
field: "heading",
|
|
47
|
+
value: heading ?? "",
|
|
48
|
+
as: "h2",
|
|
49
|
+
className: "text-[clamp(1.75rem,4vw,2.75rem)] font-light",
|
|
50
|
+
style: { fontFamily: HEADING, color: TEXT },
|
|
51
|
+
placeholder: "Books",
|
|
52
|
+
})}
|
|
53
|
+
</div>
|
|
54
|
+
<div className={`grid gap-6 sm:gap-8 ${COLS[Number(columns)] ?? COLS[3]}`}>
|
|
55
|
+
{books.map((book, i) => {
|
|
56
|
+
const href = book.links?.[0]?.url;
|
|
57
|
+
const card = (
|
|
58
|
+
<>
|
|
59
|
+
<div
|
|
60
|
+
className="aspect-[2/3] w-full overflow-hidden shadow-md transition-transform duration-300 group-hover:-translate-y-1"
|
|
61
|
+
style={{ borderRadius: RADIUS, background: HAIRLINE }}
|
|
62
|
+
>
|
|
63
|
+
{book.coverUrl ? (
|
|
64
|
+
<img src={book.coverUrl} alt={book.title} loading="lazy" className="h-full w-full object-cover" />
|
|
65
|
+
) : (
|
|
66
|
+
<div
|
|
67
|
+
className="flex h-full w-full items-center justify-center p-4 text-center text-[13px]"
|
|
68
|
+
style={{ color: MUTED, fontFamily: BODY }}
|
|
69
|
+
>
|
|
70
|
+
{book.title}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
<p className="mt-3 text-[15px] leading-snug" style={{ fontFamily: HEADING, color: TEXT }}>
|
|
75
|
+
{book.title}
|
|
76
|
+
</p>
|
|
77
|
+
{book.subtitle ? (
|
|
78
|
+
<p className="text-[11px] uppercase tracking-[0.18em]" style={{ fontFamily: BODY, color: MUTED }}>
|
|
79
|
+
{book.subtitle}
|
|
80
|
+
</p>
|
|
81
|
+
) : null}
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
return href ? (
|
|
85
|
+
<a key={i} href={href} target="_blank" rel="noopener noreferrer" className="group block">
|
|
86
|
+
{card}
|
|
87
|
+
</a>
|
|
88
|
+
) : (
|
|
89
|
+
<div key={i} className="group block">
|
|
90
|
+
{card}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</div>
|
|
95
|
+
</Section>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Featured-book spotlight — large cover beside title, blurb, and buy links.
|
|
3
|
+
* `item` is presentational; the app resolves a real book into it (live data),
|
|
4
|
+
* so this same block works for any app with a "books" concept.
|
|
5
|
+
*/
|
|
6
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
7
|
+
import {
|
|
8
|
+
AccentLink,
|
|
9
|
+
BODY,
|
|
10
|
+
type BookItem,
|
|
11
|
+
Eyebrow,
|
|
12
|
+
HAIRLINE,
|
|
13
|
+
HEADING,
|
|
14
|
+
MUTED,
|
|
15
|
+
RADIUS,
|
|
16
|
+
Section,
|
|
17
|
+
TEXT,
|
|
18
|
+
} from "./primitives";
|
|
19
|
+
|
|
20
|
+
export interface BookSpotlightProps {
|
|
21
|
+
eyebrow?: string;
|
|
22
|
+
heading?: string;
|
|
23
|
+
item?: BookItem;
|
|
24
|
+
layout?: "cover-left" | "cover-right";
|
|
25
|
+
edit?: EditPrimitives;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PLACEHOLDER: BookItem = {
|
|
29
|
+
title: "Your Latest Book",
|
|
30
|
+
subtitle: "A Novel",
|
|
31
|
+
description:
|
|
32
|
+
"Drop in the blurb that makes a reader stop scrolling. One or two sentences of the hook, the stakes, the promise.",
|
|
33
|
+
links: [{ label: "Amazon", url: "#" }, { label: "Bookshop", url: "#" }],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function BookSpotlight({
|
|
37
|
+
eyebrow,
|
|
38
|
+
heading,
|
|
39
|
+
item,
|
|
40
|
+
layout = "cover-left",
|
|
41
|
+
edit,
|
|
42
|
+
}: BookSpotlightProps) {
|
|
43
|
+
const book = item && item.title ? item : PLACEHOLDER;
|
|
44
|
+
const coverRight = layout === "cover-right";
|
|
45
|
+
return (
|
|
46
|
+
<Section>
|
|
47
|
+
{(eyebrow || heading) && (
|
|
48
|
+
<div className="mb-10 text-center">
|
|
49
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
50
|
+
{heading
|
|
51
|
+
? renderText(edit, {
|
|
52
|
+
field: "heading",
|
|
53
|
+
value: heading,
|
|
54
|
+
as: "h2",
|
|
55
|
+
className: "text-[clamp(1.75rem,4vw,2.75rem)] font-light",
|
|
56
|
+
style: { fontFamily: HEADING, color: TEXT },
|
|
57
|
+
placeholder: "Featured book",
|
|
58
|
+
})
|
|
59
|
+
: null}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
<div className="grid items-center gap-10 md:grid-cols-[0.8fr_1fr] md:gap-16">
|
|
63
|
+
<div
|
|
64
|
+
className={`mx-auto w-full max-w-[320px] ${coverRight ? "md:order-2" : ""}`}
|
|
65
|
+
>
|
|
66
|
+
<div
|
|
67
|
+
className="aspect-[2/3] w-full overflow-hidden shadow-xl"
|
|
68
|
+
style={{ borderRadius: RADIUS, background: MUTED }}
|
|
69
|
+
>
|
|
70
|
+
{book.coverUrl ? (
|
|
71
|
+
<img src={book.coverUrl} alt={book.title} loading="lazy" className="h-full w-full object-cover" />
|
|
72
|
+
) : (
|
|
73
|
+
<div
|
|
74
|
+
className="flex h-full w-full items-center justify-center p-6 text-center text-sm"
|
|
75
|
+
style={{ color: MUTED, background: HAIRLINE, fontFamily: BODY }}
|
|
76
|
+
>
|
|
77
|
+
{book.title}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<div className={coverRight ? "md:order-1" : ""}>
|
|
83
|
+
<h3 className="text-[clamp(1.5rem,3.5vw,2.25rem)] font-light leading-tight" style={{ fontFamily: HEADING, color: TEXT }}>
|
|
84
|
+
{book.title}
|
|
85
|
+
</h3>
|
|
86
|
+
{book.subtitle ? (
|
|
87
|
+
<p className="mt-1 text-sm uppercase tracking-[0.2em]" style={{ fontFamily: BODY, color: MUTED }}>
|
|
88
|
+
{book.subtitle}
|
|
89
|
+
</p>
|
|
90
|
+
) : null}
|
|
91
|
+
{book.description ? (
|
|
92
|
+
<p className="mt-5 max-w-prose text-[1.0625rem] leading-relaxed" style={{ fontFamily: BODY, color: MUTED }}>
|
|
93
|
+
{book.description}
|
|
94
|
+
</p>
|
|
95
|
+
) : null}
|
|
96
|
+
{book.links && book.links.length > 0 ? (
|
|
97
|
+
<div className="mt-7 flex flex-wrap gap-3">
|
|
98
|
+
{book.links.map((l, i) => (
|
|
99
|
+
<AccentLink key={i} href={l.url} solid={i === 0}>
|
|
100
|
+
{l.label}
|
|
101
|
+
</AccentLink>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
) : null}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</Section>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Contact / footer — heading, a line, an email, and social links. */
|
|
2
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
3
|
+
import { ACCENT, BODY, Eyebrow, HEADING, MUTED, Section, TEXT } from "./primitives";
|
|
4
|
+
|
|
5
|
+
export interface ContactProps {
|
|
6
|
+
eyebrow?: string;
|
|
7
|
+
heading?: string;
|
|
8
|
+
body?: string;
|
|
9
|
+
email?: string;
|
|
10
|
+
links?: { label: string; url: string }[];
|
|
11
|
+
edit?: EditPrimitives;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Contact({ eyebrow, heading = "Get in touch", body, email, links, edit }: ContactProps) {
|
|
15
|
+
return (
|
|
16
|
+
<Section narrow>
|
|
17
|
+
<div className="text-center">
|
|
18
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
19
|
+
{renderText(edit, {
|
|
20
|
+
field: "heading",
|
|
21
|
+
value: heading,
|
|
22
|
+
as: "h2",
|
|
23
|
+
className: "text-[clamp(1.75rem,4vw,2.75rem)] font-light",
|
|
24
|
+
style: { fontFamily: HEADING, color: TEXT },
|
|
25
|
+
placeholder: "Get in touch",
|
|
26
|
+
})}
|
|
27
|
+
{renderText(edit, {
|
|
28
|
+
field: "body",
|
|
29
|
+
value: body ?? "",
|
|
30
|
+
as: "p",
|
|
31
|
+
className: "mx-auto mt-4 max-w-md text-[1.0625rem] leading-relaxed",
|
|
32
|
+
style: { fontFamily: BODY, color: MUTED },
|
|
33
|
+
placeholder: "For rights, events, or just to say hello.",
|
|
34
|
+
multiline: true,
|
|
35
|
+
})}
|
|
36
|
+
{email ? (
|
|
37
|
+
<a
|
|
38
|
+
href={`mailto:${email}`}
|
|
39
|
+
className="mt-6 inline-block text-lg underline-offset-4 hover:underline"
|
|
40
|
+
style={{ fontFamily: HEADING, color: ACCENT }}
|
|
41
|
+
>
|
|
42
|
+
{email}
|
|
43
|
+
</a>
|
|
44
|
+
) : null}
|
|
45
|
+
{links && links.length > 0 ? (
|
|
46
|
+
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
|
47
|
+
{links.map((l, i) => (
|
|
48
|
+
<a
|
|
49
|
+
key={i}
|
|
50
|
+
href={l.url || "#"}
|
|
51
|
+
target={l.url?.startsWith("http") ? "_blank" : undefined}
|
|
52
|
+
rel={l.url?.startsWith("http") ? "noopener noreferrer" : undefined}
|
|
53
|
+
className="text-[13px] uppercase tracking-[0.2em] transition-opacity hover:opacity-70"
|
|
54
|
+
style={{ fontFamily: BODY, color: TEXT }}
|
|
55
|
+
>
|
|
56
|
+
{l.label}
|
|
57
|
+
</a>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
) : null}
|
|
61
|
+
</div>
|
|
62
|
+
</Section>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FAQ — accordion. Items from a `list` field: `question | answer` per line.
|
|
3
|
+
* Uses native <details> so it's RSC-safe AND works without JS (progressive
|
|
4
|
+
* enhancement). Portable, edit-aware.
|
|
5
|
+
*/
|
|
6
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
7
|
+
import { BODY, HEADING, MUTED, Section, TEXT } from "./primitives";
|
|
8
|
+
|
|
9
|
+
export interface FaqProps {
|
|
10
|
+
heading?: string;
|
|
11
|
+
/** `question | answer` per line. */
|
|
12
|
+
items?: string;
|
|
13
|
+
edit?: EditPrimitives;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Qa {
|
|
17
|
+
q: string;
|
|
18
|
+
a: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseItems(raw?: string): Qa[] {
|
|
22
|
+
if (!raw) return [];
|
|
23
|
+
return raw
|
|
24
|
+
.split("\n")
|
|
25
|
+
.map((l) => l.trim())
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((l) => {
|
|
28
|
+
const [q = "", a = ""] = l.split("|").map((s) => (s ?? "").trim());
|
|
29
|
+
return { q: q || "Question", a: a || "" };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Faq(props: FaqProps) {
|
|
34
|
+
const { heading, items, edit } = props;
|
|
35
|
+
const qa = parseItems(items);
|
|
36
|
+
return (
|
|
37
|
+
<Section narrow>
|
|
38
|
+
{heading &&
|
|
39
|
+
renderText(edit, {
|
|
40
|
+
field: "heading",
|
|
41
|
+
value: heading,
|
|
42
|
+
as: "h2",
|
|
43
|
+
placeholder: "FAQ",
|
|
44
|
+
className: "mb-8 text-center text-[clamp(1.6rem,4vw,2.5rem)] font-light",
|
|
45
|
+
style: { fontFamily: HEADING, color: TEXT },
|
|
46
|
+
})}
|
|
47
|
+
<div className="divide-y" style={{ borderColor: "color-mix(in srgb, currentColor 12%, transparent)" }}>
|
|
48
|
+
{qa.map((item, i) => (
|
|
49
|
+
<details key={i} className="group py-4">
|
|
50
|
+
<summary
|
|
51
|
+
className="flex cursor-pointer list-none items-center justify-between gap-4 text-[16px] font-medium"
|
|
52
|
+
style={{ fontFamily: BODY, color: TEXT }}
|
|
53
|
+
>
|
|
54
|
+
<span>{item.q}</span>
|
|
55
|
+
<span className="text-xl opacity-50 transition group-open:rotate-45">+</span>
|
|
56
|
+
</summary>
|
|
57
|
+
<p className="mt-3 text-[15px] leading-relaxed" style={{ fontFamily: BODY, color: MUTED }}>
|
|
58
|
+
{item.a}
|
|
59
|
+
</p>
|
|
60
|
+
</details>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
</Section>
|
|
64
|
+
);
|
|
65
|
+
}
|