@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
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publisher catalog for `@jimmy_harika/websitekit`.
|
|
3
|
+
*
|
|
4
|
+
* Composes the shared, design-system-agnostic blocks (`../../blocks`) into engine
|
|
5
|
+
* `SectionDefinition`s for publishing-house / press websites. Lives in the package
|
|
6
|
+
* (not in any one app) so every app that serves publishers inherits it — and
|
|
7
|
+
* improvements here upgrade all of them. Reuses the SAME block components as the
|
|
8
|
+
* author catalog, re-framed with publisher copy, labels, and defaults.
|
|
9
|
+
*
|
|
10
|
+
* Live data (the press's real catalogue of titles) is NOT fetched here: book
|
|
11
|
+
* blocks declare `items`/`item` in their schema and carry a `source: "live"`
|
|
12
|
+
* descriptor; the consuming app resolves the live list into those props before
|
|
13
|
+
* render and strips them before save (so the site stays live). See the app-side
|
|
14
|
+
* book resolver.
|
|
15
|
+
*/
|
|
16
|
+
import { type ComponentType } from "react";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import {
|
|
19
|
+
buildCatalog,
|
|
20
|
+
type Catalog,
|
|
21
|
+
type FieldDescriptor,
|
|
22
|
+
type SectionDefinition,
|
|
23
|
+
type SectionVariant,
|
|
24
|
+
} from "../../registry";
|
|
25
|
+
import {
|
|
26
|
+
AuthorBio,
|
|
27
|
+
AuthorHero,
|
|
28
|
+
BookGrid,
|
|
29
|
+
BookSpotlight,
|
|
30
|
+
Contact,
|
|
31
|
+
Newsletter,
|
|
32
|
+
} from "../../blocks";
|
|
33
|
+
|
|
34
|
+
/* ── helpers ─────────────────────────────────────────────────────────────── */
|
|
35
|
+
/** Erase a strongly-typed block component to the engine's prop-agnostic slot.
|
|
36
|
+
* (The engine injects `edit` + validated props at runtime.) */
|
|
37
|
+
const comp = (C: ComponentType<never>): ComponentType<Record<string, unknown>> =>
|
|
38
|
+
C as unknown as ComponentType<Record<string, unknown>>;
|
|
39
|
+
|
|
40
|
+
const text = (key: string, label: string, placeholder?: string): FieldDescriptor => ({
|
|
41
|
+
key,
|
|
42
|
+
label,
|
|
43
|
+
widget: "text",
|
|
44
|
+
...(placeholder ? { placeholder } : {}),
|
|
45
|
+
});
|
|
46
|
+
const textarea = (key: string, label: string): FieldDescriptor => ({ key, label, widget: "textarea" });
|
|
47
|
+
const image = (key: string, label: string): FieldDescriptor => ({ key, label, widget: "image" });
|
|
48
|
+
const select = (
|
|
49
|
+
key: string,
|
|
50
|
+
label: string,
|
|
51
|
+
options: { value: string; label: string }[],
|
|
52
|
+
): FieldDescriptor => ({ key, label, widget: "select", options });
|
|
53
|
+
const list = (key: string, label: string, help: string): FieldDescriptor => ({
|
|
54
|
+
key,
|
|
55
|
+
label,
|
|
56
|
+
widget: "list",
|
|
57
|
+
help,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const bookItemSchema = z.object({
|
|
61
|
+
id: z.string().optional(),
|
|
62
|
+
title: z.string(),
|
|
63
|
+
subtitle: z.string().optional(),
|
|
64
|
+
coverUrl: z.string().optional(),
|
|
65
|
+
description: z.string().optional(),
|
|
66
|
+
links: z.array(z.object({ label: z.string(), url: z.string() })).optional(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Parse a "Label | https://url" list-field string into a links array. */
|
|
70
|
+
function parseLinks(raw: unknown): { label: string; url: string }[] | undefined {
|
|
71
|
+
if (typeof raw !== "string" || !raw.trim()) return undefined;
|
|
72
|
+
const links = raw
|
|
73
|
+
.split("\n")
|
|
74
|
+
.map((line) => line.split("|").map((s) => s.trim()))
|
|
75
|
+
.filter((parts) => parts[0])
|
|
76
|
+
.map(([label, u]) => ({ label: label ?? "", url: u ?? "#" }));
|
|
77
|
+
return links.length ? links : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
81
|
+
1. HEADER — publisherHero
|
|
82
|
+
════════════════════════════════════════════════════════════════════════════ */
|
|
83
|
+
const publisherHero: SectionDefinition = {
|
|
84
|
+
type: "publisherHero",
|
|
85
|
+
label: "Publisher Hero",
|
|
86
|
+
category: "Header",
|
|
87
|
+
description: "Your press name + positioning line, over a banner or as a tall type-only masthead.",
|
|
88
|
+
schema: z.object({
|
|
89
|
+
eyebrow: z.string().optional(),
|
|
90
|
+
name: z.string(),
|
|
91
|
+
tagline: z.string().optional(),
|
|
92
|
+
portrait: z.string().optional(),
|
|
93
|
+
align: z.enum(["center", "left"]).optional(),
|
|
94
|
+
}),
|
|
95
|
+
defaults: {
|
|
96
|
+
eyebrow: "Independent Press · Est. 1992",
|
|
97
|
+
name: "Lantern House",
|
|
98
|
+
tagline: "An independent press for ambitious literary fiction and the books that outlast their season.",
|
|
99
|
+
portrait: "https://images.unsplash.com/photo-1507842217343-583bb7270b66?w=1600&q=80",
|
|
100
|
+
align: "center",
|
|
101
|
+
},
|
|
102
|
+
inline: { name: "text", tagline: "text", portrait: "image" },
|
|
103
|
+
variants: [
|
|
104
|
+
{ id: "portrait", label: "Over banner", props: { align: "center" } },
|
|
105
|
+
{ id: "type-only", label: "Type only", props: { align: "center", portrait: "" } },
|
|
106
|
+
{ id: "split", label: "Split", props: { align: "left" } },
|
|
107
|
+
] satisfies SectionVariant[],
|
|
108
|
+
fields: [
|
|
109
|
+
text("eyebrow", "Eyebrow", "Small line above the press name"),
|
|
110
|
+
text("name", "Press name"),
|
|
111
|
+
text("tagline", "Positioning line"),
|
|
112
|
+
image("portrait", "Banner image"),
|
|
113
|
+
select("align", "Layout", [
|
|
114
|
+
{ value: "center", label: "Centered over banner" },
|
|
115
|
+
{ value: "left", label: "Split (text + banner)" },
|
|
116
|
+
]),
|
|
117
|
+
],
|
|
118
|
+
Component: comp(AuthorHero),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
122
|
+
2. STORY — aboutPress
|
|
123
|
+
════════════════════════════════════════════════════════════════════════════ */
|
|
124
|
+
const aboutPress: SectionDefinition = {
|
|
125
|
+
type: "aboutPress",
|
|
126
|
+
label: "About the press",
|
|
127
|
+
category: "Story",
|
|
128
|
+
description: "The story of the house — beside an image of the office, shelves, or team.",
|
|
129
|
+
schema: z.object({
|
|
130
|
+
eyebrow: z.string().optional(),
|
|
131
|
+
heading: z.string(),
|
|
132
|
+
body: z.string(),
|
|
133
|
+
portrait: z.string().optional(),
|
|
134
|
+
flip: z.boolean().optional(),
|
|
135
|
+
}),
|
|
136
|
+
defaults: {
|
|
137
|
+
eyebrow: "About the press",
|
|
138
|
+
heading: "Publishing since 1992.",
|
|
139
|
+
body: "We're a small house with a long memory. For three decades we've published writers other presses passed on, and stayed with them book after book.\n\nWe believe a backlist is a promise, not an archive. This is the part where you tell readers and booksellers what your house stands for.",
|
|
140
|
+
portrait: "https://images.unsplash.com/photo-1521587760476-6c12a4b040da?w=1400&q=80",
|
|
141
|
+
flip: false,
|
|
142
|
+
},
|
|
143
|
+
inline: { heading: "text", body: "text", portrait: "image" },
|
|
144
|
+
variants: [
|
|
145
|
+
{ id: "image-left", label: "Image left", props: { flip: false } },
|
|
146
|
+
{ id: "image-right", label: "Image right", props: { flip: true } },
|
|
147
|
+
] satisfies SectionVariant[],
|
|
148
|
+
fields: [
|
|
149
|
+
text("eyebrow", "Eyebrow"),
|
|
150
|
+
text("heading", "Heading"),
|
|
151
|
+
textarea("body", "Body"),
|
|
152
|
+
image("portrait", "Image"),
|
|
153
|
+
],
|
|
154
|
+
Component: comp(AuthorBio),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
158
|
+
3. CATALOGUE — featuredTitle, catalogGrid (items resolved live by the app)
|
|
159
|
+
════════════════════════════════════════════════════════════════════════════ */
|
|
160
|
+
const featuredTitle: SectionDefinition = {
|
|
161
|
+
type: "featuredTitle",
|
|
162
|
+
label: "Featured Title",
|
|
163
|
+
category: "Catalogue",
|
|
164
|
+
description: "Feature one title — cover, blurb, and buy links. Pulls your latest release by default.",
|
|
165
|
+
schema: z.object({
|
|
166
|
+
eyebrow: z.string().optional(),
|
|
167
|
+
heading: z.string().optional(),
|
|
168
|
+
layout: z.enum(["cover-left", "cover-right"]).optional(),
|
|
169
|
+
source: z.enum(["live", "manual"]).optional(),
|
|
170
|
+
item: bookItemSchema.optional(),
|
|
171
|
+
}),
|
|
172
|
+
defaults: {
|
|
173
|
+
eyebrow: "New release",
|
|
174
|
+
heading: "This season's lead title",
|
|
175
|
+
layout: "cover-left",
|
|
176
|
+
source: "live",
|
|
177
|
+
},
|
|
178
|
+
inline: { heading: "text" },
|
|
179
|
+
variants: [
|
|
180
|
+
{ id: "cover-left", label: "Cover left", props: { layout: "cover-left" } },
|
|
181
|
+
{ id: "cover-right", label: "Cover right", props: { layout: "cover-right" } },
|
|
182
|
+
] satisfies SectionVariant[],
|
|
183
|
+
fields: [
|
|
184
|
+
text("eyebrow", "Eyebrow"),
|
|
185
|
+
text("heading", "Heading"),
|
|
186
|
+
select("layout", "Cover side", [
|
|
187
|
+
{ value: "cover-left", label: "Left" },
|
|
188
|
+
{ value: "cover-right", label: "Right" },
|
|
189
|
+
]),
|
|
190
|
+
],
|
|
191
|
+
Component: comp(BookSpotlight),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const catalogGrid: SectionDefinition = {
|
|
195
|
+
type: "catalogGrid",
|
|
196
|
+
label: "Catalogue Grid",
|
|
197
|
+
category: "Catalogue",
|
|
198
|
+
description: "Your list — a responsive grid of the press's titles.",
|
|
199
|
+
schema: z.object({
|
|
200
|
+
eyebrow: z.string().optional(),
|
|
201
|
+
heading: z.string().optional(),
|
|
202
|
+
columns: z.union([z.number(), z.string()]).optional(),
|
|
203
|
+
limit: z.number().optional(),
|
|
204
|
+
source: z.enum(["live", "manual"]).optional(),
|
|
205
|
+
items: z.array(bookItemSchema).optional(),
|
|
206
|
+
}),
|
|
207
|
+
defaults: {
|
|
208
|
+
eyebrow: "The list",
|
|
209
|
+
heading: "Our Catalogue",
|
|
210
|
+
columns: 3,
|
|
211
|
+
limit: 12,
|
|
212
|
+
source: "live",
|
|
213
|
+
},
|
|
214
|
+
inline: { heading: "text" },
|
|
215
|
+
fields: [
|
|
216
|
+
text("eyebrow", "Eyebrow"),
|
|
217
|
+
text("heading", "Heading"),
|
|
218
|
+
select("columns", "Columns", [
|
|
219
|
+
{ value: "2", label: "2" },
|
|
220
|
+
{ value: "3", label: "3" },
|
|
221
|
+
{ value: "4", label: "4" },
|
|
222
|
+
]),
|
|
223
|
+
],
|
|
224
|
+
Component: comp(BookGrid),
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
228
|
+
4. CONNECT — newsletter, contact
|
|
229
|
+
════════════════════════════════════════════════════════════════════════════ */
|
|
230
|
+
const newsletter: SectionDefinition = {
|
|
231
|
+
type: "newsletter",
|
|
232
|
+
label: "Newsletter",
|
|
233
|
+
category: "Connect",
|
|
234
|
+
description: "Email capture for new-release announcements from the press.",
|
|
235
|
+
schema: z.object({
|
|
236
|
+
eyebrow: z.string().optional(),
|
|
237
|
+
heading: z.string().optional(),
|
|
238
|
+
body: z.string().optional(),
|
|
239
|
+
cta: z.string().optional(),
|
|
240
|
+
placeholder: z.string().optional(),
|
|
241
|
+
actionUrl: z.string().optional(),
|
|
242
|
+
}),
|
|
243
|
+
defaults: {
|
|
244
|
+
eyebrow: "Newsletter",
|
|
245
|
+
heading: "New releases from the press",
|
|
246
|
+
body: "Season announcements, cover reveals, and news from our authors. No spam.",
|
|
247
|
+
cta: "Subscribe",
|
|
248
|
+
},
|
|
249
|
+
fields: [
|
|
250
|
+
text("eyebrow", "Eyebrow"),
|
|
251
|
+
text("heading", "Heading"),
|
|
252
|
+
textarea("body", "Body"),
|
|
253
|
+
text("cta", "Button label"),
|
|
254
|
+
],
|
|
255
|
+
Component: comp(Newsletter),
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** Contact wraps the block to parse the inspector `socials` list-string into a
|
|
259
|
+
* `links` array (the list widget stores a raw string). */
|
|
260
|
+
const ContactWrapped: ComponentType<Record<string, unknown>> = (props) => {
|
|
261
|
+
const links = parseLinks(props.socials) ?? (props.links as { label: string; url: string }[] | undefined);
|
|
262
|
+
return <Contact {...(props as Record<string, never>)} links={links} />;
|
|
263
|
+
};
|
|
264
|
+
ContactWrapped.displayName = "ContactWrapped";
|
|
265
|
+
|
|
266
|
+
const contact: SectionDefinition = {
|
|
267
|
+
type: "contact",
|
|
268
|
+
label: "Contact",
|
|
269
|
+
category: "Connect",
|
|
270
|
+
description: "Rights, submissions, and contact — plus social links to close the page.",
|
|
271
|
+
schema: z.object({
|
|
272
|
+
eyebrow: z.string().optional(),
|
|
273
|
+
heading: z.string().optional(),
|
|
274
|
+
body: z.string().optional(),
|
|
275
|
+
email: z.string().optional(),
|
|
276
|
+
socials: z.string().optional(),
|
|
277
|
+
links: z.array(z.object({ label: z.string(), url: z.string() })).optional(),
|
|
278
|
+
}),
|
|
279
|
+
defaults: {
|
|
280
|
+
eyebrow: "Rights & submissions",
|
|
281
|
+
heading: "Work with the press",
|
|
282
|
+
body: "Submissions, foreign and translation rights, press and events — start here.",
|
|
283
|
+
email: "submissions@yourpress.com",
|
|
284
|
+
socials: "Instagram | https://instagram.com\nBluesky | https://bsky.app",
|
|
285
|
+
},
|
|
286
|
+
inline: { heading: "text", body: "text" },
|
|
287
|
+
fields: [
|
|
288
|
+
text("eyebrow", "Eyebrow"),
|
|
289
|
+
text("heading", "Heading"),
|
|
290
|
+
textarea("body", "Body"),
|
|
291
|
+
text("email", "Email"),
|
|
292
|
+
list("socials", "Social links", "One per line: Label | https://url"),
|
|
293
|
+
],
|
|
294
|
+
Component: ContactWrapped,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/** The publisher catalog — ordered to emit: Header → Story → Catalogue → Connect. */
|
|
298
|
+
export const PUBLISHER_CATALOG: Catalog = buildCatalog([
|
|
299
|
+
publisherHero,
|
|
300
|
+
aboutPress,
|
|
301
|
+
catalogGrid,
|
|
302
|
+
featuredTitle,
|
|
303
|
+
newsletter,
|
|
304
|
+
contact,
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
/** Section types whose `items`/`item` the app should resolve from live books. */
|
|
308
|
+
export const PUBLISHER_BOOK_SECTION_TYPES = ["featuredTitle", "catalogGrid"] as const;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@jimmy_harika/websitekit/catalogs/publisher` — the publisher-website vertical:
|
|
3
|
+
* catalog (block definitions) + starter templates for publishing houses. A
|
|
4
|
+
* consuming app imports `PUBLISHER_CATALOG` for the editor + renderer,
|
|
5
|
+
* `PUBLISHER_TEMPLATES` for the template gallery, and resolves live titles into
|
|
6
|
+
* `PUBLISHER_BOOK_SECTION_TYPES`.
|
|
7
|
+
*/
|
|
8
|
+
export { PUBLISHER_CATALOG, PUBLISHER_BOOK_SECTION_TYPES } from "./catalog";
|
|
9
|
+
export { PUBLISHER_TEMPLATES, blankPublisherDocument } from "./templates";
|
|
10
|
+
export { type BookItem } from "../../blocks";
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publisher website templates — complete starter sites on the publisher catalog.
|
|
3
|
+
* Each `build()` returns a themed `PageDocument`. Catalogue sections carry only a
|
|
4
|
+
* `source: "live"` descriptor; the app injects the press's real titles at render.
|
|
5
|
+
*/
|
|
6
|
+
import { createSectionId, type PageDocument, type Section, type TemplateMeta } from "../../model";
|
|
7
|
+
|
|
8
|
+
const sec = (type: string, props: Record<string, unknown>, variant?: string): Section => ({
|
|
9
|
+
id: createSectionId(),
|
|
10
|
+
type,
|
|
11
|
+
...(variant ? { variant } : {}),
|
|
12
|
+
props,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/* ── Folio — classic literary serif, cream ──────────────────────────────────── */
|
|
16
|
+
function folio(): PageDocument {
|
|
17
|
+
return {
|
|
18
|
+
theme: {
|
|
19
|
+
fontHeading: "Cormorant Garamond",
|
|
20
|
+
fontBody: "Jost",
|
|
21
|
+
colors: { bg: "#f7f4ed", text: "#221f1a", primary: "#221f1a", accent: "#7c5a36", muted: "#6f6a62" },
|
|
22
|
+
radius: "sm",
|
|
23
|
+
spacing: "roomy",
|
|
24
|
+
},
|
|
25
|
+
sections: [
|
|
26
|
+
sec("publisherHero", {
|
|
27
|
+
eyebrow: "Independent Literary Press · Est. 1992",
|
|
28
|
+
name: "Lantern House",
|
|
29
|
+
tagline: "An independent press for ambitious literary fiction and the books that outlast their season.",
|
|
30
|
+
portrait: "https://images.unsplash.com/photo-1507842217343-583bb7270b66?w=1600&q=80",
|
|
31
|
+
align: "center",
|
|
32
|
+
}, "portrait"),
|
|
33
|
+
sec("aboutPress", {
|
|
34
|
+
eyebrow: "About the press",
|
|
35
|
+
heading: "Publishing since 1992.",
|
|
36
|
+
body: "We're a small house with a long memory. For three decades we've published writers other presses passed on, and stayed with them book after book — first novels, mid-career risks, the quiet masterpiece nobody saw coming.\n\nWe believe a backlist is a promise, not an archive. Every title we print, we keep in print.",
|
|
37
|
+
portrait: "https://images.unsplash.com/photo-1521587760476-6c12a4b040da?w=1400&q=80",
|
|
38
|
+
flip: false,
|
|
39
|
+
}, "image-left"),
|
|
40
|
+
sec("catalogGrid", { eyebrow: "The list", heading: "Our Catalogue", columns: 3, limit: 12, source: "live" }),
|
|
41
|
+
sec("featuredTitle", {
|
|
42
|
+
eyebrow: "New release",
|
|
43
|
+
heading: "This season's lead title",
|
|
44
|
+
layout: "cover-left",
|
|
45
|
+
source: "live",
|
|
46
|
+
}, "cover-left"),
|
|
47
|
+
sec("newsletter", {
|
|
48
|
+
eyebrow: "Newsletter",
|
|
49
|
+
heading: "New releases from the press",
|
|
50
|
+
body: "Season announcements, cover reveals, and news from our authors. No spam, ever.",
|
|
51
|
+
cta: "Subscribe",
|
|
52
|
+
}),
|
|
53
|
+
sec("contact", {
|
|
54
|
+
eyebrow: "Rights & submissions",
|
|
55
|
+
heading: "Work with the press",
|
|
56
|
+
body: "We read unagented submissions twice a year. Foreign and translation rights, press, and events — start here.",
|
|
57
|
+
email: "submissions@lanternhouse.press",
|
|
58
|
+
socials: "Instagram | https://instagram.com\nBluesky | https://bsky.app",
|
|
59
|
+
}),
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ── Press — modern sans, ink-dark ─────────────────────────────────────────── */
|
|
65
|
+
function press(): PageDocument {
|
|
66
|
+
return {
|
|
67
|
+
theme: {
|
|
68
|
+
fontHeading: "Space Grotesk",
|
|
69
|
+
fontBody: "Inter",
|
|
70
|
+
colors: { bg: "#101114", text: "#f1f0ee", primary: "#f1f0ee", accent: "#e0563b", muted: "#9a978f" },
|
|
71
|
+
radius: "md",
|
|
72
|
+
spacing: "normal",
|
|
73
|
+
},
|
|
74
|
+
sections: [
|
|
75
|
+
sec("publisherHero", {
|
|
76
|
+
eyebrow: "Independent Publisher · Since 2009",
|
|
77
|
+
name: "North Press",
|
|
78
|
+
tagline: "We publish books that argue with the present — narrative nonfiction, ideas, and design that travels.",
|
|
79
|
+
portrait: "https://images.unsplash.com/photo-1481627834876-b7833e8f5570?w=1600&q=80",
|
|
80
|
+
align: "center",
|
|
81
|
+
}, "portrait"),
|
|
82
|
+
sec("featuredTitle", {
|
|
83
|
+
eyebrow: "Out now",
|
|
84
|
+
heading: "The new one",
|
|
85
|
+
layout: "cover-right",
|
|
86
|
+
source: "live",
|
|
87
|
+
}, "cover-right"),
|
|
88
|
+
sec("aboutPress", {
|
|
89
|
+
eyebrow: "About the press",
|
|
90
|
+
heading: "A house built for the long argument.",
|
|
91
|
+
body: "Founded in 2009 around a simple bet: that serious books, made beautifully, still find their people. We publish fifteen titles a year and over-invest in every one — the edit, the cover, the campaign.\n\nNo filler list, no churn. If we sign it, we're with it for a decade.",
|
|
92
|
+
portrait: "https://images.unsplash.com/photo-1526243741027-444d633d7365?w=1400&q=80",
|
|
93
|
+
flip: true,
|
|
94
|
+
}, "image-right"),
|
|
95
|
+
sec("catalogGrid", { eyebrow: "The list", heading: "Our Catalogue", columns: 4, limit: 16, source: "live" }),
|
|
96
|
+
sec("newsletter", {
|
|
97
|
+
eyebrow: "The list",
|
|
98
|
+
heading: "New releases from the press",
|
|
99
|
+
body: "Season catalogues, early excerpts, and event invites — straight to your inbox.",
|
|
100
|
+
cta: "Join",
|
|
101
|
+
}),
|
|
102
|
+
sec("contact", {
|
|
103
|
+
eyebrow: "Rights & submissions",
|
|
104
|
+
heading: "Get in touch",
|
|
105
|
+
body: "Agented submissions and subsidiary rights to the editorial desk; press and events below.",
|
|
106
|
+
email: "rights@northpress.co",
|
|
107
|
+
socials: "X | https://x.com\nLinkedIn | https://linkedin.com",
|
|
108
|
+
}),
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const PUBLISHER_TEMPLATES: TemplateMeta[] = [
|
|
114
|
+
{
|
|
115
|
+
id: "folio",
|
|
116
|
+
name: "Folio",
|
|
117
|
+
category: "Publisher",
|
|
118
|
+
description: "Classic literary serif on cream — for independent and literary presses.",
|
|
119
|
+
build: folio,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "press",
|
|
123
|
+
name: "Press",
|
|
124
|
+
category: "Publisher",
|
|
125
|
+
description: "Modern sans, ink-dark and bold — for nonfiction, ideas, and design-led houses.",
|
|
126
|
+
build: press,
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/** The blank starting point when no template is chosen. */
|
|
131
|
+
export function blankPublisherDocument(): PageDocument {
|
|
132
|
+
return {
|
|
133
|
+
theme: {
|
|
134
|
+
fontHeading: "Cormorant Garamond",
|
|
135
|
+
fontBody: "Jost",
|
|
136
|
+
colors: { bg: "#f7f4ed", text: "#221f1a", primary: "#221f1a", accent: "#7c5a36", muted: "#6f6a62" },
|
|
137
|
+
radius: "sm",
|
|
138
|
+
spacing: "roomy",
|
|
139
|
+
},
|
|
140
|
+
sections: [
|
|
141
|
+
sec("publisherHero", {
|
|
142
|
+
eyebrow: "Independent Press · Est. 19XX",
|
|
143
|
+
name: "Your Press",
|
|
144
|
+
tagline: "A line that says what your house publishes, and who for.",
|
|
145
|
+
portrait: "https://images.unsplash.com/photo-1507842217343-583bb7270b66?w=1600&q=80",
|
|
146
|
+
align: "center",
|
|
147
|
+
}),
|
|
148
|
+
sec("catalogGrid", { eyebrow: "The list", heading: "Our Catalogue", columns: 3, limit: 12, source: "live" }),
|
|
149
|
+
sec("contact", {
|
|
150
|
+
eyebrow: "Rights & submissions",
|
|
151
|
+
heading: "Work with the press",
|
|
152
|
+
body: "Submissions, rights, press, and events — start here.",
|
|
153
|
+
email: "submissions@yourpress.com",
|
|
154
|
+
socials: "Instagram | https://instagram.com\nBluesky | https://bsky.app",
|
|
155
|
+
}),
|
|
156
|
+
],
|
|
157
|
+
};
|
|
158
|
+
}
|
package/src/editable.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block ↔ editor bridge — the trick that lets ONE block component render in both
|
|
3
|
+
* production (pure RSC, no JS) and the editor canvas (inline editable), with no
|
|
4
|
+
* divergence.
|
|
5
|
+
*
|
|
6
|
+
* A block receives an optional `edit` prop. In production it is absent, so
|
|
7
|
+
* `renderText`/`renderImage` emit plain server elements. In the editor the
|
|
8
|
+
* canvas injects client primitives, so the same fields become inline-editable.
|
|
9
|
+
* Blocks opt in simply by using these helpers instead of raw `{value}`.
|
|
10
|
+
*/
|
|
11
|
+
import { createElement, type CSSProperties, type ElementType, type ReactNode } from "react";
|
|
12
|
+
|
|
13
|
+
export interface TextEditProps {
|
|
14
|
+
field: string;
|
|
15
|
+
value: string;
|
|
16
|
+
as?: ElementType;
|
|
17
|
+
className?: string;
|
|
18
|
+
style?: CSSProperties;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
multiline?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ImageEditProps {
|
|
24
|
+
field: string;
|
|
25
|
+
src?: string;
|
|
26
|
+
alt?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
style?: CSSProperties;
|
|
29
|
+
/** Intrinsic pixel dimensions — emitted to avoid CLS. */
|
|
30
|
+
width?: number;
|
|
31
|
+
height?: number;
|
|
32
|
+
/** Mark the LCP/above-the-fold image so it loads eagerly + high-priority. */
|
|
33
|
+
priority?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Injected by the editor canvas; absent in production. */
|
|
37
|
+
export interface EditPrimitives {
|
|
38
|
+
Text: ElementType<TextEditProps>;
|
|
39
|
+
Image: ElementType<ImageEditProps>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Prop convention every section Component spreads. */
|
|
43
|
+
export interface WithEdit {
|
|
44
|
+
edit?: EditPrimitives;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Render an inline-editable (editor) or static (production) text field. */
|
|
48
|
+
export function renderText(
|
|
49
|
+
edit: EditPrimitives | undefined,
|
|
50
|
+
props: TextEditProps,
|
|
51
|
+
): ReactNode {
|
|
52
|
+
if (edit) return createElement(edit.Text, props);
|
|
53
|
+
const { as = "span", value, className, style, placeholder } = props;
|
|
54
|
+
return createElement(as, { className, style }, value || placeholder || "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Render an inline-editable (editor) or static (production) image field. */
|
|
58
|
+
export function renderImage(
|
|
59
|
+
edit: EditPrimitives | undefined,
|
|
60
|
+
props: ImageEditProps,
|
|
61
|
+
): ReactNode {
|
|
62
|
+
if (edit) return createElement(edit.Image, props);
|
|
63
|
+
const { src, alt = "", className, style, width, height, priority } = props;
|
|
64
|
+
if (!src) return null;
|
|
65
|
+
return createElement("img", {
|
|
66
|
+
src,
|
|
67
|
+
alt,
|
|
68
|
+
className,
|
|
69
|
+
style,
|
|
70
|
+
...(width ? { width } : {}),
|
|
71
|
+
...(height ? { height } : {}),
|
|
72
|
+
loading: priority ? "eager" : "lazy",
|
|
73
|
+
// fetchPriority (React 19) — high for the LCP image, auto otherwise.
|
|
74
|
+
fetchPriority: priority ? "high" : "auto",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The "Add section" library (screenshot #3): categories on the left, live mini
|
|
5
|
+
* previews of each section + its layout variants on the right. Click inserts at
|
|
6
|
+
* the chosen index. Previews render the real catalog Component (scaled), so what
|
|
7
|
+
* you see is what you get.
|
|
8
|
+
*/
|
|
9
|
+
import { createElement, useEffect, useState } from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
11
|
+
import { X } from "lucide-react";
|
|
12
|
+
import {
|
|
13
|
+
type Catalog,
|
|
14
|
+
type SectionDefinition,
|
|
15
|
+
defaultPropsFor,
|
|
16
|
+
} from "../registry";
|
|
17
|
+
import { createSectionId } from "../model";
|
|
18
|
+
import { themeVars } from "../theme";
|
|
19
|
+
import { useBuilder } from "../store";
|
|
20
|
+
import { useEditorUi } from "./ui-context";
|
|
21
|
+
import { cn } from "../lib/cn";
|
|
22
|
+
|
|
23
|
+
export function BlockLibrary({ catalog }: { catalog: Catalog }) {
|
|
24
|
+
const ui = useEditorUi();
|
|
25
|
+
const theme = useBuilder((s) => s.doc.theme);
|
|
26
|
+
const insertSection = useBuilder((s) => s.insertSection);
|
|
27
|
+
const [active, setActive] = useState(catalog.categories[0] ?? "");
|
|
28
|
+
const [mounted, setMounted] = useState(false);
|
|
29
|
+
useEffect(() => setMounted(true), []);
|
|
30
|
+
|
|
31
|
+
if (ui.libraryIndex === null || !mounted) return null;
|
|
32
|
+
const items = Object.values(catalog.sections).filter((d) => d.category === active);
|
|
33
|
+
|
|
34
|
+
function add(def: SectionDefinition, variant?: string) {
|
|
35
|
+
insertSection(
|
|
36
|
+
{
|
|
37
|
+
id: createSectionId(),
|
|
38
|
+
type: def.type,
|
|
39
|
+
variant,
|
|
40
|
+
props: defaultPropsFor(def, variant),
|
|
41
|
+
},
|
|
42
|
+
ui.libraryIndex ?? undefined,
|
|
43
|
+
);
|
|
44
|
+
ui.closeLibrary();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return createPortal(
|
|
48
|
+
<div className="fixed inset-0 z-[100] flex">
|
|
49
|
+
<div className="flex-1 bg-black/50" onClick={() => ui.closeLibrary()} />
|
|
50
|
+
<div className="flex h-full w-full max-w-3xl flex-col bg-card text-foreground shadow-2xl">
|
|
51
|
+
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
|
52
|
+
<h2 className="text-sm font-semibold">Add section</h2>
|
|
53
|
+
<button
|
|
54
|
+
onClick={() => ui.closeLibrary()}
|
|
55
|
+
className="rounded p-1 hover:bg-accent"
|
|
56
|
+
>
|
|
57
|
+
<X className="size-5" />
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
<div className="flex min-h-0 flex-1">
|
|
61
|
+
<nav className="w-40 shrink-0 overflow-auto border-r p-2">
|
|
62
|
+
{catalog.categories.map((c) => (
|
|
63
|
+
<button
|
|
64
|
+
key={c}
|
|
65
|
+
onClick={() => setActive(c)}
|
|
66
|
+
className={cn(
|
|
67
|
+
"block w-full rounded px-3 py-2 text-left text-sm",
|
|
68
|
+
active === c
|
|
69
|
+
? "bg-secondary font-medium text-secondary-foreground"
|
|
70
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{c}
|
|
74
|
+
</button>
|
|
75
|
+
))}
|
|
76
|
+
</nav>
|
|
77
|
+
<div className="grid flex-1 grid-cols-1 content-start gap-4 overflow-auto p-4 sm:grid-cols-2">
|
|
78
|
+
{items.flatMap((def) => {
|
|
79
|
+
const variants =
|
|
80
|
+
def.variants && def.variants.length
|
|
81
|
+
? def.variants
|
|
82
|
+
: [{ id: undefined as string | undefined, label: def.label }];
|
|
83
|
+
return variants.map((v) => (
|
|
84
|
+
<button
|
|
85
|
+
key={def.type + (v.id ?? "")}
|
|
86
|
+
onClick={() => add(def, v.id)}
|
|
87
|
+
className="group overflow-hidden rounded-lg border bg-muted text-left transition hover:border-sky-400 hover:shadow"
|
|
88
|
+
>
|
|
89
|
+
<div className="relative h-32 overflow-hidden bg-background">
|
|
90
|
+
<div
|
|
91
|
+
className="pointer-events-none absolute left-0 top-0 origin-top-left"
|
|
92
|
+
style={{ ...themeVars(theme), width: 1100, transform: "scale(0.318)" }}
|
|
93
|
+
>
|
|
94
|
+
{createElement(def.Component, defaultPropsFor(def, v.id))}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="border-t px-3 py-2 text-xs font-medium text-foreground">
|
|
98
|
+
{def.label}
|
|
99
|
+
{variants.length > 1 ? ` · ${v.label}` : ""}
|
|
100
|
+
</div>
|
|
101
|
+
</button>
|
|
102
|
+
));
|
|
103
|
+
})}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>,
|
|
108
|
+
document.body,
|
|
109
|
+
);
|
|
110
|
+
}
|