@bygghammar/cms-platform 0.58.0 → 0.59.0
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.59.0
|
|
4
|
+
|
|
5
|
+
- Added `site/routes/public-page`, `site/routes/preview-page` and
|
|
6
|
+
`site/routes/section-preview`: the catch-all public page route engine, the
|
|
7
|
+
page-builder preview (draft store, editor handshake, block selection) and the
|
|
8
|
+
standalone content-section preview are now package-owned. Sites keep thin
|
|
9
|
+
route wrappers supplying data sources, chrome and copy.
|
|
10
|
+
- Drift guard manifest now covers the three route wrappers.
|
|
11
|
+
|
|
3
12
|
## 0.58.0
|
|
4
13
|
|
|
5
14
|
- Utilities sweep: the public-page context loader (`site/page-context`), the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -212,7 +212,10 @@
|
|
|
212
212
|
"./site/SiteHeader": "./src/site/SiteHeader.tsx",
|
|
213
213
|
"./site/SiteFooter": "./src/site/SiteFooter.tsx",
|
|
214
214
|
"./server/schemas": "./src/server/schemas.ts",
|
|
215
|
-
"./server/page-seo-save-core": "./src/server/page-seo-save-core.ts"
|
|
215
|
+
"./server/page-seo-save-core": "./src/server/page-seo-save-core.ts",
|
|
216
|
+
"./site/routes/public-page": "./src/site/routes/public-page.tsx",
|
|
217
|
+
"./site/routes/preview-page": "./src/site/routes/preview-page.tsx",
|
|
218
|
+
"./site/routes/section-preview": "./src/site/routes/section-preview.tsx"
|
|
216
219
|
},
|
|
217
220
|
"dependencies": {
|
|
218
221
|
"fflate": "^0.8.3"
|
|
@@ -290,4 +293,4 @@
|
|
|
290
293
|
"cms-platform-migrations": "scripts/sync-migrations.mjs",
|
|
291
294
|
"create-cms-site": "./scripts/create-cms-site.mjs"
|
|
292
295
|
}
|
|
293
|
-
}
|
|
296
|
+
}
|
|
@@ -60,4 +60,16 @@ export const PACKAGE_OWNED_MODULES: OwnedModule[] = [
|
|
|
60
60
|
},
|
|
61
61
|
{ site: "src/lib/blocks/registry.tsx", packageModule: "@bygghammar/cms-platform/blocks/registry" },
|
|
62
62
|
{ site: "src/lib/site/definition.ts", packageModule: "@bygghammar/cms-platform/config/define-site" },
|
|
63
|
+
{
|
|
64
|
+
site: "src/routes/$.tsx",
|
|
65
|
+
packageModule: "@bygghammar/cms-platform/site/routes/public-page",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
site: "src/routes/preview.$key.tsx",
|
|
69
|
+
packageModule: "@bygghammar/cms-platform/site/routes/preview-page",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
site: "src/routes/preview.section.$key.tsx",
|
|
73
|
+
packageModule: "@bygghammar/cms-platform/site/routes/section-preview",
|
|
74
|
+
},
|
|
63
75
|
];
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-builder preview route (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* The preview renders either an unsaved draft (from the local draft store or a
|
|
5
|
+
* postMessage stream from the builder) or the published version of a page, and
|
|
6
|
+
* talks back to the editor: ready handshake, block selection, scroll-to-block.
|
|
7
|
+
* All of that is platform behaviour; a site supplies its data sources, chrome
|
|
8
|
+
* and draft store only.
|
|
9
|
+
*/
|
|
10
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { useEffect, useState, type ComponentType } from "react";
|
|
12
|
+
|
|
13
|
+
import { LocaleProvider, isLocale, DEFAULT_LOCALE, type Locale } from "../../i18n";
|
|
14
|
+
import type { BlockInstance } from "../../blocks/types";
|
|
15
|
+
|
|
16
|
+
export type PreviewDraftSnapshot = {
|
|
17
|
+
blocks: BlockInstance[];
|
|
18
|
+
globals?: Record<string, BlockInstance[]>;
|
|
19
|
+
locale?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type PreviewPageDeps = {
|
|
23
|
+
/** Published blocks + globals for a page key. */
|
|
24
|
+
getPublishedPage: (key: string) => Promise<PreviewDraftSnapshot | null | undefined>;
|
|
25
|
+
/** Saved blocks for a global section key (preview keys prefixed `gs:`). */
|
|
26
|
+
getGlobalSection: (key: string) => Promise<{ blocks: BlockInstance[] } | null | undefined>;
|
|
27
|
+
/** Local draft store written by the builder. */
|
|
28
|
+
readDraft: (key: string) => PreviewDraftSnapshot | null;
|
|
29
|
+
subscribeDraft: (key: string, cb: (snap: PreviewDraftSnapshot) => void) => () => void;
|
|
30
|
+
/** In-memory renderer for a block tree. */
|
|
31
|
+
DraftPageRenderer: ComponentType<{
|
|
32
|
+
blocks: BlockInstance[];
|
|
33
|
+
globals: Record<string, BlockInstance[]>;
|
|
34
|
+
edit?: boolean;
|
|
35
|
+
selectedId?: string | null;
|
|
36
|
+
}>;
|
|
37
|
+
Header: ComponentType;
|
|
38
|
+
Footer: ComponentType;
|
|
39
|
+
/** Query key holding shared content sections, patched from inspector edits. */
|
|
40
|
+
sharedContentQueryKey: readonly unknown[];
|
|
41
|
+
/** Page key rendered without the header offset (usually the home page). */
|
|
42
|
+
fullBleedKey?: string;
|
|
43
|
+
/** Outline colour used for block hover/selection. */
|
|
44
|
+
outlineColorVar?: string;
|
|
45
|
+
copy?: { draft?: string; published?: string; loading?: string };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type DraftMsg = {
|
|
49
|
+
source: "lovable-editor";
|
|
50
|
+
type: "draft" | "scrollTo";
|
|
51
|
+
blocks: BlockInstance[];
|
|
52
|
+
globals: Record<string, BlockInstance[]>;
|
|
53
|
+
selectedId?: string | null;
|
|
54
|
+
blockId?: string;
|
|
55
|
+
locale?: string;
|
|
56
|
+
sharedContent?: Record<string, unknown>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKey: string }> {
|
|
60
|
+
const copy = {
|
|
61
|
+
draft: deps.copy?.draft ?? "Draft preview — unsaved changes",
|
|
62
|
+
published: deps.copy?.published ?? "Preview — published version",
|
|
63
|
+
loading: deps.copy?.loading ?? "Loading preview…",
|
|
64
|
+
};
|
|
65
|
+
const outline = deps.outlineColorVar ?? "var(--primary, #2ac4ea)";
|
|
66
|
+
|
|
67
|
+
return function PreviewPage({ pageKey: key }) {
|
|
68
|
+
const queryClient = useQueryClient();
|
|
69
|
+
const [blocks, setBlocks] = useState<BlockInstance[]>([]);
|
|
70
|
+
const [globals, setGlobals] = useState<Record<string, BlockInstance[]>>({});
|
|
71
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
72
|
+
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
|
73
|
+
const [ready, setReady] = useState(false);
|
|
74
|
+
const [isDraft, setIsDraft] = useState(false);
|
|
75
|
+
const embedded = typeof window !== "undefined" && window.parent !== window;
|
|
76
|
+
|
|
77
|
+
// Bootstrap: prefer a local unsaved draft, else the saved version.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
let alive = true;
|
|
80
|
+
const local = deps.readDraft(key);
|
|
81
|
+
if (local) {
|
|
82
|
+
setBlocks(local.blocks);
|
|
83
|
+
setGlobals(local.globals ?? {});
|
|
84
|
+
if (local.locale && isLocale(local.locale)) setLocale(local.locale);
|
|
85
|
+
setIsDraft(true);
|
|
86
|
+
setReady(true);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (key.startsWith("gs:")) {
|
|
90
|
+
deps
|
|
91
|
+
.getGlobalSection(key.slice(3))
|
|
92
|
+
.then((res) => {
|
|
93
|
+
if (!alive || !res) return;
|
|
94
|
+
setBlocks((prev) => (prev.length ? prev : res.blocks));
|
|
95
|
+
})
|
|
96
|
+
.catch(() => {})
|
|
97
|
+
.finally(() => alive && setReady(true));
|
|
98
|
+
return () => {
|
|
99
|
+
alive = false;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
deps
|
|
103
|
+
.getPublishedPage(key)
|
|
104
|
+
.then((res) => {
|
|
105
|
+
if (!alive || !res) return;
|
|
106
|
+
setBlocks((prev) => (prev.length ? prev : res.blocks));
|
|
107
|
+
setGlobals((prev) => (Object.keys(prev).length ? prev : (res.globals ?? {})));
|
|
108
|
+
})
|
|
109
|
+
.catch(() => {})
|
|
110
|
+
.finally(() => alive && setReady(true));
|
|
111
|
+
return () => {
|
|
112
|
+
alive = false;
|
|
113
|
+
};
|
|
114
|
+
}, [key]);
|
|
115
|
+
|
|
116
|
+
// Live sync with the builder in another tab.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (embedded) return;
|
|
119
|
+
return deps.subscribeDraft(key, (snap) => {
|
|
120
|
+
setBlocks(snap.blocks);
|
|
121
|
+
setGlobals(snap.globals ?? {});
|
|
122
|
+
if (snap.locale && isLocale(snap.locale)) setLocale(snap.locale);
|
|
123
|
+
setIsDraft(true);
|
|
124
|
+
setReady(true);
|
|
125
|
+
});
|
|
126
|
+
}, [key, embedded]);
|
|
127
|
+
|
|
128
|
+
// Locale from the URL (?locale=en) wins for standalone tabs.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (typeof window === "undefined") return;
|
|
131
|
+
const q = new URLSearchParams(window.location.search).get("locale");
|
|
132
|
+
if (q && isLocale(q)) setLocale(q);
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
// Ready handshake plus draft/scroll messages from the editor.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const send = (msg: unknown) =>
|
|
138
|
+
window.parent && window.parent !== window && window.parent.postMessage(msg, "*");
|
|
139
|
+
send({ source: "lovable-preview", type: "ready" });
|
|
140
|
+
const onMsg = (ev: MessageEvent) => {
|
|
141
|
+
const data = ev.data as DraftMsg | undefined;
|
|
142
|
+
if (!data || data.source !== "lovable-editor") return;
|
|
143
|
+
if (data.type === "scrollTo") {
|
|
144
|
+
const id = data.blockId;
|
|
145
|
+
if (!id) return;
|
|
146
|
+
requestAnimationFrame(() => {
|
|
147
|
+
document
|
|
148
|
+
.querySelector(`[data-block-id="${CSS.escape(id)}"]`)
|
|
149
|
+
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (data.type !== "draft") return;
|
|
154
|
+
if (data.sharedContent && Object.keys(data.sharedContent).length) {
|
|
155
|
+
queryClient.setQueryData(
|
|
156
|
+
deps.sharedContentQueryKey,
|
|
157
|
+
(prev: Record<string, unknown> | undefined) => ({
|
|
158
|
+
...(prev ?? {}),
|
|
159
|
+
...data.sharedContent,
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
setBlocks(data.blocks);
|
|
164
|
+
setGlobals(data.globals);
|
|
165
|
+
setSelectedId(data.selectedId ?? null);
|
|
166
|
+
if (data.locale && isLocale(data.locale)) setLocale(data.locale);
|
|
167
|
+
setIsDraft(true);
|
|
168
|
+
setReady(true);
|
|
169
|
+
};
|
|
170
|
+
window.addEventListener("message", onMsg);
|
|
171
|
+
return () => window.removeEventListener("message", onMsg);
|
|
172
|
+
}, [queryClient]);
|
|
173
|
+
|
|
174
|
+
// Intercept clicks and report the outermost block to the editor.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const onClick = (e: MouseEvent) => {
|
|
177
|
+
if (window.parent === window) return;
|
|
178
|
+
const target = e.target as HTMLElement | null;
|
|
179
|
+
let el = target?.closest("[data-block-id]") as HTMLElement | null;
|
|
180
|
+
if (!el) return;
|
|
181
|
+
let outer = el.parentElement?.closest("[data-block-id]") as HTMLElement | null;
|
|
182
|
+
while (outer) {
|
|
183
|
+
el = outer;
|
|
184
|
+
outer = el.parentElement?.closest("[data-block-id]") as HTMLElement | null;
|
|
185
|
+
}
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
e.stopPropagation();
|
|
188
|
+
window.parent.postMessage(
|
|
189
|
+
{
|
|
190
|
+
source: "lovable-preview",
|
|
191
|
+
type: "select",
|
|
192
|
+
blockId: el.getAttribute("data-block-id"),
|
|
193
|
+
},
|
|
194
|
+
"*",
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
document.addEventListener("click", onClick, true);
|
|
198
|
+
return () => document.removeEventListener("click", onClick, true);
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<LocaleProvider locale={locale}>
|
|
203
|
+
<style>{`
|
|
204
|
+
.lovable-block { position: relative; }
|
|
205
|
+
.lovable-block::after {
|
|
206
|
+
content: ""; position: absolute; inset: 0; pointer-events: none;
|
|
207
|
+
outline: 2px dashed transparent; outline-offset: -2px;
|
|
208
|
+
transition: outline-color .15s ease; z-index: 40;
|
|
209
|
+
}
|
|
210
|
+
.lovable-block:hover::after { outline-color: ${outline}; }
|
|
211
|
+
.lovable-block[data-selected="true"]::after {
|
|
212
|
+
outline: 3px solid ${outline}; outline-offset: -3px;
|
|
213
|
+
}
|
|
214
|
+
a, button { cursor: pointer; }
|
|
215
|
+
`}</style>
|
|
216
|
+
<main className="min-h-screen bg-background text-foreground overflow-x-hidden">
|
|
217
|
+
{!embedded && (
|
|
218
|
+
<div className="fixed bottom-4 left-4 z-[100] border border-ink/15 bg-white/95 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.18em] text-ink/70 shadow-lg">
|
|
219
|
+
{isDraft ? copy.draft : copy.published}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
<deps.Header />
|
|
223
|
+
<div className={key === (deps.fullBleedKey ?? "home") ? "" : "pt-24 md:pt-32"}>
|
|
224
|
+
{ready ? (
|
|
225
|
+
<deps.DraftPageRenderer
|
|
226
|
+
blocks={blocks}
|
|
227
|
+
globals={globals}
|
|
228
|
+
edit
|
|
229
|
+
selectedId={selectedId}
|
|
230
|
+
/>
|
|
231
|
+
) : (
|
|
232
|
+
<div className="p-12 text-ink/50 text-sm">{copy.loading}</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
<deps.Footer />
|
|
236
|
+
</main>
|
|
237
|
+
</LocaleProvider>
|
|
238
|
+
);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public catch-all page route (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* Every site resolves an incoming path to a CMS page key, honours stored
|
|
5
|
+
* redirects, warms the page + page-context queries and renders the page between
|
|
6
|
+
* the site chrome. Only the data sources, chrome components and copy differ per
|
|
7
|
+
* site, so the engine lives here and the site keeps a thin route wrapper.
|
|
8
|
+
*/
|
|
9
|
+
import { notFound, redirect } from "@tanstack/react-router";
|
|
10
|
+
import type { ComponentType, ReactNode } from "react";
|
|
11
|
+
|
|
12
|
+
export type ResolvedPath =
|
|
13
|
+
| { kind: "page"; pageKey: string }
|
|
14
|
+
| { kind: "redirect"; to: string; status?: number }
|
|
15
|
+
| { kind: "none" };
|
|
16
|
+
|
|
17
|
+
export type PublicPageHead = {
|
|
18
|
+
meta?: Array<Record<string, string>>;
|
|
19
|
+
links?: Array<Record<string, string>>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type QueryLike = { queryKey: readonly unknown[] };
|
|
23
|
+
|
|
24
|
+
export type PublicPageRouteDeps<Ctx> = {
|
|
25
|
+
/** Resolve a request path to a page key, a redirect, or nothing. */
|
|
26
|
+
resolvePath: (path: string) => Promise<ResolvedPath>;
|
|
27
|
+
/** Page-context query (SEO rows, locale, origin, domains). */
|
|
28
|
+
pageContextQueryOptions: QueryLike;
|
|
29
|
+
/** Published-page query for one page key. */
|
|
30
|
+
pageQueryOptions: (pageKey: string) => QueryLike;
|
|
31
|
+
/** Head builder from `createPageContext`. */
|
|
32
|
+
buildPageHead: (pageKey: string, context: Ctx | undefined) => PublicPageHead;
|
|
33
|
+
/** Site chrome. */
|
|
34
|
+
Header: ComponentType;
|
|
35
|
+
Footer: ComponentType;
|
|
36
|
+
/** Published-page renderer. */
|
|
37
|
+
PageRenderer: ComponentType<{ pageKey: string }>;
|
|
38
|
+
/** Localized-free copy for the fallback screens (already resolved strings). */
|
|
39
|
+
copy: { notFoundTitle: string; errorText: string };
|
|
40
|
+
/** Padding applied above the page content; defaults to the header offset. */
|
|
41
|
+
contentClassName?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type EnsureQueryData = <T extends QueryLike>(options: T) => Promise<unknown>;
|
|
45
|
+
type LoaderArgs = {
|
|
46
|
+
context: { queryClient: { ensureQueryData: EnsureQueryData } };
|
|
47
|
+
params: { _splat?: string };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type PublicPageRoute<Ctx> = {
|
|
51
|
+
loader: (args: LoaderArgs) => Promise<{ pageKey: string; ctx: Ctx }>;
|
|
52
|
+
head: (args: { loaderData?: { pageKey: string; ctx: Ctx } }) => PublicPageHead;
|
|
53
|
+
PublicPage: ComponentType<{ pageKey: string }>;
|
|
54
|
+
ErrorScreen: ComponentType;
|
|
55
|
+
NotFoundScreen: ComponentType;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function createPublicPageRoute<Ctx>(deps: PublicPageRouteDeps<Ctx>): PublicPageRoute<Ctx> {
|
|
59
|
+
const Shell = ({ children }: { children: ReactNode }) => (
|
|
60
|
+
<main className="min-h-screen bg-background text-foreground overflow-x-hidden">
|
|
61
|
+
<deps.Header />
|
|
62
|
+
{children}
|
|
63
|
+
<deps.Footer />
|
|
64
|
+
</main>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
async loader({ context, params }) {
|
|
69
|
+
const path = `/${params._splat ?? ""}`;
|
|
70
|
+
const res = await deps.resolvePath(path);
|
|
71
|
+
if (res.kind === "redirect") {
|
|
72
|
+
throw redirect({ href: res.to, statusCode: res.status === 302 ? 302 : 301 });
|
|
73
|
+
}
|
|
74
|
+
if (res.kind !== "page") throw notFound();
|
|
75
|
+
|
|
76
|
+
const [ctx] = await Promise.all([
|
|
77
|
+
context.queryClient.ensureQueryData(deps.pageContextQueryOptions),
|
|
78
|
+
context.queryClient.ensureQueryData(deps.pageQueryOptions(res.pageKey)),
|
|
79
|
+
]);
|
|
80
|
+
return { pageKey: res.pageKey, ctx: ctx as Ctx };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
head({ loaderData }) {
|
|
84
|
+
if (!loaderData) {
|
|
85
|
+
return {
|
|
86
|
+
meta: [{ title: deps.copy.notFoundTitle }, { name: "robots", content: "noindex" }],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return deps.buildPageHead(loaderData.pageKey, loaderData.ctx);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
PublicPage: ({ pageKey }) => (
|
|
93
|
+
<Shell>
|
|
94
|
+
<div className={deps.contentClassName ?? "pt-24 md:pt-32"}>
|
|
95
|
+
<deps.PageRenderer pageKey={pageKey} />
|
|
96
|
+
</div>
|
|
97
|
+
</Shell>
|
|
98
|
+
),
|
|
99
|
+
|
|
100
|
+
ErrorScreen: () => (
|
|
101
|
+
<Shell>
|
|
102
|
+
<div className="pt-40 pb-32 text-center text-ink/70">{deps.copy.errorText}</div>
|
|
103
|
+
</Shell>
|
|
104
|
+
),
|
|
105
|
+
|
|
106
|
+
NotFoundScreen: () => (
|
|
107
|
+
<Shell>
|
|
108
|
+
<div className="pt-40 pb-32 text-center">
|
|
109
|
+
<h1 className="font-serif text-4xl text-ink">{deps.copy.notFoundTitle}</h1>
|
|
110
|
+
</div>
|
|
111
|
+
</Shell>
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single content-section preview (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* The backoffice thumbnail lightbox opens one section standalone so an editor
|
|
5
|
+
* sees the real site component rather than a screenshot. The section map is the
|
|
6
|
+
* only site-specific part.
|
|
7
|
+
*/
|
|
8
|
+
import type { ComponentType } from "react";
|
|
9
|
+
|
|
10
|
+
export type SectionPreviewDeps = {
|
|
11
|
+
/** Section key -> real site component. Missing keys render the fallback. */
|
|
12
|
+
sections: Record<string, ComponentType>;
|
|
13
|
+
copy?: { title?: string; body?: string };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createSectionPreview(
|
|
17
|
+
deps: SectionPreviewDeps,
|
|
18
|
+
): ComponentType<{ sectionKey: string }> {
|
|
19
|
+
const title = deps.copy?.title ?? "No visual preview";
|
|
20
|
+
const body =
|
|
21
|
+
deps.copy?.body ?? "This section holds settings and text used elsewhere on the site.";
|
|
22
|
+
|
|
23
|
+
return function SectionPreview({ sectionKey }) {
|
|
24
|
+
const Cmp = deps.sections[sectionKey];
|
|
25
|
+
return (
|
|
26
|
+
<main className="min-h-screen bg-background text-foreground overflow-x-hidden">
|
|
27
|
+
{Cmp ? (
|
|
28
|
+
<Cmp />
|
|
29
|
+
) : (
|
|
30
|
+
<div className="flex min-h-screen items-center justify-center p-10 text-center">
|
|
31
|
+
<div>
|
|
32
|
+
<div className="font-serif text-2xl text-ink">{title}</div>
|
|
33
|
+
<p className="mt-2 text-sm text-ink/60">{body}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
)}
|
|
37
|
+
</main>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
}
|