@notionx/create-notionx-app 1.0.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/README.md +139 -0
- package/dist/answers.js +332 -0
- package/dist/answers.js.map +1 -0
- package/dist/cli-notionx.js +388 -0
- package/dist/cli-notionx.js.map +1 -0
- package/dist/cli-notionx.test.js +277 -0
- package/dist/cli-notionx.test.js.map +1 -0
- package/dist/diff.js +40 -0
- package/dist/diff.js.map +1 -0
- package/dist/diff.test.js +90 -0
- package/dist/diff.test.js.map +1 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +1 -0
- package/dist/locale-add/apply.js +39 -0
- package/dist/locale-add/apply.js.map +1 -0
- package/dist/locale-add/format.js +38 -0
- package/dist/locale-add/format.js.map +1 -0
- package/dist/locale-add/list.js +44 -0
- package/dist/locale-add/list.js.map +1 -0
- package/dist/locale-add/list.test.js +45 -0
- package/dist/locale-add/list.test.js.map +1 -0
- package/dist/locale-add/plan.js +128 -0
- package/dist/locale-add/plan.js.map +1 -0
- package/dist/locale-add/validate.js +46 -0
- package/dist/locale-add/validate.js.map +1 -0
- package/dist/metadata.js +41 -0
- package/dist/metadata.js.map +1 -0
- package/dist/notion-translation-sources/apply.js +61 -0
- package/dist/notion-translation-sources/apply.js.map +1 -0
- package/dist/notion-translation-sources/index.js +3 -0
- package/dist/notion-translation-sources/index.js.map +1 -0
- package/dist/notion-translation-sources/plan.js +33 -0
- package/dist/notion-translation-sources/plan.js.map +1 -0
- package/dist/notionx-source.js +142 -0
- package/dist/notionx-source.js.map +1 -0
- package/dist/notionx-source.test.js +144 -0
- package/dist/notionx-source.test.js.map +1 -0
- package/dist/password.js +18 -0
- package/dist/password.js.map +1 -0
- package/dist/presets.js +83 -0
- package/dist/presets.js.map +1 -0
- package/dist/presets.test.js +50 -0
- package/dist/presets.test.js.map +1 -0
- package/dist/prompt.js +218 -0
- package/dist/prompt.js.map +1 -0
- package/dist/provision/cloudflare.js +236 -0
- package/dist/provision/cloudflare.js.map +1 -0
- package/dist/provision/dependencies.js +219 -0
- package/dist/provision/dependencies.js.map +1 -0
- package/dist/provision/index.js +681 -0
- package/dist/provision/index.js.map +1 -0
- package/dist/provision/index.test.js +54 -0
- package/dist/provision/index.test.js.map +1 -0
- package/dist/provision/inspect.js +109 -0
- package/dist/provision/inspect.js.map +1 -0
- package/dist/provision/inspect.test.js +75 -0
- package/dist/provision/inspect.test.js.map +1 -0
- package/dist/provision/notion.js +1981 -0
- package/dist/provision/notion.js.map +1 -0
- package/dist/provision/notion.test.js +542 -0
- package/dist/provision/notion.test.js.map +1 -0
- package/dist/provision/ntn-credentials.js +198 -0
- package/dist/provision/ntn-credentials.js.map +1 -0
- package/dist/provision/options.js +15 -0
- package/dist/provision/options.js.map +1 -0
- package/dist/provision/password-hash.js +78 -0
- package/dist/provision/password-hash.js.map +1 -0
- package/dist/provision/prompts.js +115 -0
- package/dist/provision/prompts.js.map +1 -0
- package/dist/provision/repair.js +48 -0
- package/dist/provision/repair.js.map +1 -0
- package/dist/provision/repair.test.js +141 -0
- package/dist/provision/repair.test.js.map +1 -0
- package/dist/provision/shell.js +84 -0
- package/dist/provision/shell.js.map +1 -0
- package/dist/provision/wire.js +78 -0
- package/dist/provision/wire.js.map +1 -0
- package/dist/registry/doctor.js +181 -0
- package/dist/registry/doctor.js.map +1 -0
- package/dist/registry/doctor.test.js +180 -0
- package/dist/registry/doctor.test.js.map +1 -0
- package/dist/registry/install.js +217 -0
- package/dist/registry/install.js.map +1 -0
- package/dist/registry/install.test.js +168 -0
- package/dist/registry/install.test.js.map +1 -0
- package/dist/registry/load-registry.js +24 -0
- package/dist/registry/load-registry.js.map +1 -0
- package/dist/registry/load-registry.test.js +59 -0
- package/dist/registry/load-registry.test.js.map +1 -0
- package/dist/registry/migration-planner.js +204 -0
- package/dist/registry/migration-planner.js.map +1 -0
- package/dist/registry/migration-planner.test.js +340 -0
- package/dist/registry/migration-planner.test.js.map +1 -0
- package/dist/registry/migrations-store.js +125 -0
- package/dist/registry/migrations-store.js.map +1 -0
- package/dist/registry/migrations-store.test.js +163 -0
- package/dist/registry/migrations-store.test.js.map +1 -0
- package/dist/registry/migrations-types.js +25 -0
- package/dist/registry/migrations-types.js.map +1 -0
- package/dist/registry/project-meta.js +84 -0
- package/dist/registry/project-meta.js.map +1 -0
- package/dist/registry/registry-items.js +354 -0
- package/dist/registry/registry-items.js.map +1 -0
- package/dist/registry/registry-items.test.js +99 -0
- package/dist/registry/registry-items.test.js.map +1 -0
- package/dist/registry/registry-store.js +232 -0
- package/dist/registry/registry-store.js.map +1 -0
- package/dist/registry/registry-store.test.js +136 -0
- package/dist/registry/registry-store.test.js.map +1 -0
- package/dist/registry/registry-types.js +18 -0
- package/dist/registry/registry-types.js.map +1 -0
- package/dist/registry/registry-types.test.js +146 -0
- package/dist/registry/registry-types.test.js.map +1 -0
- package/dist/registry/render-content-source-files.js +158 -0
- package/dist/registry/render-content-source-files.js.map +1 -0
- package/dist/registry/render-multi-source.js +296 -0
- package/dist/registry/render-multi-source.js.map +1 -0
- package/dist/registry/render-multi-source.test.js +110 -0
- package/dist/registry/render-multi-source.test.js.map +1 -0
- package/dist/registry/text-utils.js +42 -0
- package/dist/registry/text-utils.js.map +1 -0
- package/dist/registry/uninstall.js +250 -0
- package/dist/registry/uninstall.js.map +1 -0
- package/dist/registry/uninstall.test.js +264 -0
- package/dist/registry/uninstall.test.js.map +1 -0
- package/dist/registry/update.js +280 -0
- package/dist/registry/update.js.map +1 -0
- package/dist/registry/update.test.js +229 -0
- package/dist/registry/update.test.js.map +1 -0
- package/dist/render.js +549 -0
- package/dist/render.js.map +1 -0
- package/dist/render.test.js +414 -0
- package/dist/render.test.js.map +1 -0
- package/dist/templates/.dev.vars.example.tmpl +32 -0
- package/dist/templates/.gitignore.tmpl +58 -0
- package/dist/templates/README.md.tmpl +417 -0
- package/dist/templates/app/[slug]/page.tsx.tmpl +55 -0
- package/dist/templates/app/admin/account/page.tsx.tmpl +18 -0
- package/dist/templates/app/admin/content-models/page.tsx.tmpl +6 -0
- package/dist/templates/app/admin/layout.tsx.tmpl +90 -0
- package/dist/templates/app/admin/loading.tsx.tmpl +6 -0
- package/dist/templates/app/admin/page.tsx.tmpl +17 -0
- package/dist/templates/app/api/auth/google/callback/route.ts.tmpl +3 -0
- package/dist/templates/app/api/auth/google/route.ts.tmpl +3 -0
- package/dist/templates/app/api/auth/verify-email/route.ts.tmpl +3 -0
- package/dist/templates/app/api/auth/viewer/route.ts.tmpl +3 -0
- package/dist/templates/app/api/health/route.ts.tmpl +3 -0
- package/dist/templates/app/api/{{contentSourceId}}/[slug]/route.ts.tmpl +27 -0
- package/dist/templates/app/api/{{contentSourceId}}/route.ts.tmpl +18 -0
- package/dist/templates/app/globals.css.tmpl +109 -0
- package/dist/templates/app/layout.tsx.tmpl +56 -0
- package/dist/templates/app/login/page.tsx.tmpl +154 -0
- package/dist/templates/app/page.fallback.tsx.tmpl +31 -0
- package/dist/templates/app/page.tsx.tmpl +42 -0
- package/dist/templates/app/register/page.tsx.tmpl +138 -0
- package/dist/templates/app/{{contentSourceListPath}}/[slug]/page.tsx.tmpl +113 -0
- package/dist/templates/app/{{contentSourceListPath}}/page.tsx.tmpl +74 -0
- package/dist/templates/components/content/post-card.tsx.tmpl +80 -0
- package/dist/templates/components/notion-blocks.tsx.tmpl +668 -0
- package/dist/templates/components/page-blocks/feature-grid-block.tsx.tmpl +68 -0
- package/dist/templates/components/page-blocks/hero-block.tsx.tmpl +73 -0
- package/dist/templates/components/page-blocks/latest-posts-block.tsx.tmpl +59 -0
- package/dist/templates/components/page-blocks/story-block.tsx.tmpl +70 -0
- package/dist/templates/components/page-blocks.fallback.tsx.tmpl +17 -0
- package/dist/templates/components/page-blocks.tsx.tmpl +32 -0
- package/dist/templates/components/search/search-dialog.tsx.tmpl +171 -0
- package/dist/templates/components/site/locale-switcher.tsx.tmpl +65 -0
- package/dist/templates/components/site/site-footer.tsx.tmpl +106 -0
- package/dist/templates/components/site/site-header.tsx.tmpl +80 -0
- package/dist/templates/components/site/site-shell.tsx.tmpl +20 -0
- package/dist/templates/components/site/theme-bootstrap.tsx.tmpl +51 -0
- package/dist/templates/components/theme-provider.tsx.tmpl +14 -0
- package/dist/templates/components/theme-toggle.tsx.tmpl +38 -0
- package/dist/templates/components/ui/accordion.tsx.tmpl +56 -0
- package/dist/templates/components/ui/alert.tsx.tmpl +59 -0
- package/dist/templates/components/ui/aspect-ratio.tsx.tmpl +8 -0
- package/dist/templates/components/ui/avatar.tsx.tmpl +44 -0
- package/dist/templates/components/ui/badge.tsx.tmpl +33 -0
- package/dist/templates/components/ui/button.tsx.tmpl +56 -0
- package/dist/templates/components/ui/card.tsx.tmpl +61 -0
- package/dist/templates/components/ui/checkbox.tsx.tmpl +28 -0
- package/dist/templates/components/ui/dialog.tsx.tmpl +104 -0
- package/dist/templates/components/ui/dropdown-menu.tsx.tmpl +183 -0
- package/dist/templates/components/ui/input.tsx.tmpl +21 -0
- package/dist/templates/components/ui/label.tsx.tmpl +25 -0
- package/dist/templates/components/ui/popover.tsx.tmpl +30 -0
- package/dist/templates/components/ui/radio-group.tsx.tmpl +44 -0
- package/dist/templates/components/ui/select.tsx.tmpl +150 -0
- package/dist/templates/components/ui/separator.tsx.tmpl +30 -0
- package/dist/templates/components/ui/sheet.tsx.tmpl +125 -0
- package/dist/templates/components/ui/skeleton.tsx.tmpl +15 -0
- package/dist/templates/components/ui/sonner.tsx.tmpl +30 -0
- package/dist/templates/components/ui/switch.tsx.tmpl +29 -0
- package/dist/templates/components/ui/table.tsx.tmpl +107 -0
- package/dist/templates/components/ui/tabs.tsx.tmpl +55 -0
- package/dist/templates/components/ui/textarea.tsx.tmpl +24 -0
- package/dist/templates/components/ui/tooltip.tsx.tmpl +30 -0
- package/dist/templates/components.json.tmpl +21 -0
- package/dist/templates/env.d.ts.tmpl +32 -0
- package/dist/templates/lib/admin/actions.ts.tmpl +43 -0
- package/dist/templates/lib/admin/context.tsx.tmpl +209 -0
- package/dist/templates/lib/admin/nav.ts.tmpl +23 -0
- package/dist/templates/lib/auth.config.fallback.ts.tmpl +10 -0
- package/dist/templates/lib/auth.config.ts.tmpl +45 -0
- package/dist/templates/lib/blocks/translations.ts.tmpl +44 -0
- package/dist/templates/lib/blog/translations.ts.tmpl +52 -0
- package/dist/templates/lib/content/models.ts.tmpl +53 -0
- package/dist/templates/lib/i18n/config.ts.tmpl +18 -0
- package/dist/templates/lib/i18n/index.ts.tmpl +1 -0
- package/dist/templates/lib/locale-contract/built-in.ts.tmpl +19 -0
- package/dist/templates/lib/locale-contract/index.ts.tmpl +3 -0
- package/dist/templates/lib/locale-contract/paths.ts.tmpl +29 -0
- package/dist/templates/lib/pages/model.ts.tmpl +16 -0
- package/dist/templates/lib/pages/source.ts.tmpl +566 -0
- package/dist/templates/lib/pages/translations.ts.tmpl +34 -0
- package/dist/templates/lib/search/config.fallback.ts.tmpl +11 -0
- package/dist/templates/lib/search/config.ts.tmpl +25 -0
- package/dist/templates/lib/site/config.ts.tmpl +120 -0
- package/dist/templates/lib/site/request-env.ts.tmpl +71 -0
- package/dist/templates/lib/site/settings.fallback.ts.tmpl +21 -0
- package/dist/templates/lib/site/settings.ts.tmpl +320 -0
- package/dist/templates/lib/site/translations.ts.tmpl +30 -0
- package/dist/templates/lib/utils.ts.tmpl +9 -0
- package/dist/templates/migrations/0001_init.sql.tmpl +57 -0
- package/dist/templates/migrations/0002_admin_seed.sql.tmpl +30 -0
- package/dist/templates/migrations/0003_search_index.sql.tmpl +29 -0
- package/dist/templates/next.config.ts.tmpl +18 -0
- package/dist/templates/package.json.tmpl +40 -0
- package/dist/templates/shims/cloudflare-workers-empty.mjs +4 -0
- package/dist/templates/shims/next-headers-empty.mjs +4 -0
- package/dist/templates/tests/smoke.test.ts.tmpl +83 -0
- package/dist/templates/tsconfig.json.tmpl +31 -0
- package/dist/templates/vite.config.ts.tmpl +53 -0
- package/dist/templates/vitest.config.ts.tmpl +13 -0
- package/dist/templates/worker/index.ts.tmpl +52 -0
- package/dist/templates/wrangler.jsonc.tmpl +44 -0
- package/dist/ui-presets.js +60 -0
- package/dist/ui-presets.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import type { NotionBlock, NotionRichTextPart } from "@notionx/core/notion";
|
|
2
|
+
import { Fragment, type ReactNode } from "react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
type RichTextContainer = {
|
|
7
|
+
rich_text?: NotionRichTextPart[];
|
|
8
|
+
caption?: NotionRichTextPart[];
|
|
9
|
+
title?: NotionRichTextPart[];
|
|
10
|
+
cells?: NotionRichTextPart[][];
|
|
11
|
+
expression?: string;
|
|
12
|
+
checked?: boolean;
|
|
13
|
+
color?: string;
|
|
14
|
+
language?: string;
|
|
15
|
+
url?: string;
|
|
16
|
+
icon?: { emoji?: string };
|
|
17
|
+
has_column_header?: boolean;
|
|
18
|
+
has_row_header?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type FileSource =
|
|
22
|
+
| { type: "external"; url: string }
|
|
23
|
+
| { type: "file"; url: string }
|
|
24
|
+
| { url?: string };
|
|
25
|
+
|
|
26
|
+
type CustomAction = { label?: string; href?: string };
|
|
27
|
+
type CustomConfig = {
|
|
28
|
+
component?: string;
|
|
29
|
+
variant?: string;
|
|
30
|
+
eyebrow?: string;
|
|
31
|
+
title?: string;
|
|
32
|
+
heading?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
body?: string;
|
|
35
|
+
href?: string;
|
|
36
|
+
label?: string;
|
|
37
|
+
primaryAction?: CustomAction;
|
|
38
|
+
secondaryAction?: CustomAction;
|
|
39
|
+
items?: Array<{ title?: string; description?: string; href?: string }>;
|
|
40
|
+
plans?: Array<{ title?: string; price?: string; description?: string; features?: string[]; href?: string }>;
|
|
41
|
+
questions?: Array<{ question?: string; answer?: string }>;
|
|
42
|
+
testimonials?: Array<{ quote?: string; author?: string; role?: string }>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type BlockGroup =
|
|
46
|
+
| { kind: "block"; block: NotionBlock }
|
|
47
|
+
| { kind: "list"; ordered: boolean; blocks: NotionBlock[] };
|
|
48
|
+
|
|
49
|
+
function typedValue<T extends Record<string, unknown> = RichTextContainer>(
|
|
50
|
+
block: NotionBlock
|
|
51
|
+
): T {
|
|
52
|
+
return ((block[block.type] ?? {}) as T) || ({} as T);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function partText(part: NotionRichTextPart): string {
|
|
56
|
+
if (part.type === "equation") return part.equation?.expression ?? "";
|
|
57
|
+
return part.plain_text ?? part.text?.content ?? "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function plainText(parts: NotionRichTextPart[] | undefined): string {
|
|
61
|
+
return parts?.map(partText).join("") ?? "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function notionColorClass(color: string | undefined): string | undefined {
|
|
65
|
+
switch (color) {
|
|
66
|
+
case "gray":
|
|
67
|
+
return "text-muted-foreground";
|
|
68
|
+
case "brown":
|
|
69
|
+
return "text-amber-900 dark:text-amber-200";
|
|
70
|
+
case "orange":
|
|
71
|
+
return "text-orange-700 dark:text-orange-300";
|
|
72
|
+
case "yellow":
|
|
73
|
+
return "text-yellow-700 dark:text-yellow-300";
|
|
74
|
+
case "green":
|
|
75
|
+
return "text-emerald-700 dark:text-emerald-300";
|
|
76
|
+
case "blue":
|
|
77
|
+
return "text-blue-700 dark:text-blue-300";
|
|
78
|
+
case "purple":
|
|
79
|
+
return "text-violet-700 dark:text-violet-300";
|
|
80
|
+
case "pink":
|
|
81
|
+
return "text-pink-700 dark:text-pink-300";
|
|
82
|
+
case "red":
|
|
83
|
+
return "text-red-700 dark:text-red-300";
|
|
84
|
+
case "gray_background":
|
|
85
|
+
return "bg-muted text-foreground";
|
|
86
|
+
case "brown_background":
|
|
87
|
+
return "bg-amber-50 text-amber-950 dark:bg-amber-950/30 dark:text-amber-100";
|
|
88
|
+
case "orange_background":
|
|
89
|
+
return "bg-orange-50 text-orange-950 dark:bg-orange-950/30 dark:text-orange-100";
|
|
90
|
+
case "yellow_background":
|
|
91
|
+
return "bg-yellow-50 text-yellow-950 dark:bg-yellow-950/30 dark:text-yellow-100";
|
|
92
|
+
case "green_background":
|
|
93
|
+
return "bg-emerald-50 text-emerald-950 dark:bg-emerald-950/30 dark:text-emerald-100";
|
|
94
|
+
case "blue_background":
|
|
95
|
+
return "bg-blue-50 text-blue-950 dark:bg-blue-950/30 dark:text-blue-100";
|
|
96
|
+
case "purple_background":
|
|
97
|
+
return "bg-violet-50 text-violet-950 dark:bg-violet-950/30 dark:text-violet-100";
|
|
98
|
+
case "pink_background":
|
|
99
|
+
return "bg-pink-50 text-pink-950 dark:bg-pink-950/30 dark:text-pink-100";
|
|
100
|
+
case "red_background":
|
|
101
|
+
return "bg-red-50 text-red-950 dark:bg-red-950/30 dark:text-red-100";
|
|
102
|
+
default:
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderRichText(parts: NotionRichTextPart[] | undefined): ReactNode {
|
|
108
|
+
if (!parts || parts.length === 0) return null;
|
|
109
|
+
return parts.map((part, idx) => {
|
|
110
|
+
const text = partText(part);
|
|
111
|
+
if (!text) return null;
|
|
112
|
+
let node: ReactNode = text;
|
|
113
|
+
if (part.annotations?.code) {
|
|
114
|
+
node = (
|
|
115
|
+
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.9em]">
|
|
116
|
+
{node}
|
|
117
|
+
</code>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (part.annotations?.bold) node = <strong>{node}</strong>;
|
|
121
|
+
if (part.annotations?.italic) node = <em>{node}</em>;
|
|
122
|
+
if (part.annotations?.strikethrough) node = <s>{node}</s>;
|
|
123
|
+
if (part.annotations?.underline) node = <u>{node}</u>;
|
|
124
|
+
const colorClass = notionColorClass(part.annotations?.color);
|
|
125
|
+
if (colorClass) node = <span className={colorClass}>{node}</span>;
|
|
126
|
+
if (part.href) {
|
|
127
|
+
node = (
|
|
128
|
+
<a
|
|
129
|
+
href={part.href}
|
|
130
|
+
target={part.href.startsWith("/") ? undefined : "_blank"}
|
|
131
|
+
rel={part.href.startsWith("/") ? undefined : "noreferrer noopener"}
|
|
132
|
+
className="text-primary underline underline-offset-4"
|
|
133
|
+
>
|
|
134
|
+
{node}
|
|
135
|
+
</a>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return <Fragment key={idx}>{node}</Fragment>;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readFileUrl(source: unknown): string | null {
|
|
143
|
+
if (!source || typeof source !== "object") return null;
|
|
144
|
+
const typed = source as FileSource;
|
|
145
|
+
return typeof typed.url === "string" && typed.url.length > 0
|
|
146
|
+
? typed.url
|
|
147
|
+
: null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readBlockFileUrl(value: Record<string, unknown>): string | null {
|
|
151
|
+
return readFileUrl(value.file ?? value.external);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function safeHref(value: unknown): string | null {
|
|
155
|
+
if (typeof value !== "string") return null;
|
|
156
|
+
const href = value.trim();
|
|
157
|
+
if (!href) return null;
|
|
158
|
+
if (href.startsWith("/")) return href;
|
|
159
|
+
if (href.startsWith("mailto:") || href.startsWith("tel:")) return href;
|
|
160
|
+
try {
|
|
161
|
+
const url = new URL(href);
|
|
162
|
+
return url.protocol === "http:" || url.protocol === "https:" ? href : null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function groupBlocks(blocks: NotionBlock[]): BlockGroup[] {
|
|
169
|
+
const groups: BlockGroup[] = [];
|
|
170
|
+
let pending: { ordered: boolean; blocks: NotionBlock[] } | null = null;
|
|
171
|
+
|
|
172
|
+
for (const block of blocks) {
|
|
173
|
+
const ordered = block.type === "numbered_list_item";
|
|
174
|
+
const isList = ordered || block.type === "bulleted_list_item";
|
|
175
|
+
if (!isList) {
|
|
176
|
+
if (pending) groups.push({ kind: "list", ...pending });
|
|
177
|
+
pending = null;
|
|
178
|
+
groups.push({ kind: "block", block });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!pending || pending.ordered !== ordered) {
|
|
182
|
+
if (pending) groups.push({ kind: "list", ...pending });
|
|
183
|
+
pending = { ordered, blocks: [block] };
|
|
184
|
+
} else {
|
|
185
|
+
pending.blocks.push(block);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (pending) groups.push({ kind: "list", ...pending });
|
|
190
|
+
return groups;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderListGroup(group: Extract<BlockGroup, { kind: "list" }>) {
|
|
194
|
+
const Tag = group.ordered ? "ol" : "ul";
|
|
195
|
+
return (
|
|
196
|
+
<Tag
|
|
197
|
+
className={cn(
|
|
198
|
+
"my-4 space-y-2 pl-6",
|
|
199
|
+
group.ordered ? "list-decimal" : "list-disc"
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
{group.blocks.map((block) => {
|
|
203
|
+
const value = typedValue(block);
|
|
204
|
+
return (
|
|
205
|
+
<li key={block.id} className="leading-7 text-foreground/90">
|
|
206
|
+
{renderRichText(value.rich_text)}
|
|
207
|
+
{block.children?.length ? (
|
|
208
|
+
<div className="mt-2">
|
|
209
|
+
<NotionBlocks blocks={block.children} />
|
|
210
|
+
</div>
|
|
211
|
+
) : null}
|
|
212
|
+
</li>
|
|
213
|
+
);
|
|
214
|
+
})}
|
|
215
|
+
</Tag>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderCustomComponent(config: CustomConfig, key: string): ReactNode {
|
|
220
|
+
const title = config.title ?? config.heading;
|
|
221
|
+
const description = config.description ?? config.body;
|
|
222
|
+
const primaryHref = safeHref(config.primaryAction?.href ?? config.href);
|
|
223
|
+
const primaryLabel = config.primaryAction?.label ?? config.label;
|
|
224
|
+
const secondaryHref = safeHref(config.secondaryAction?.href);
|
|
225
|
+
|
|
226
|
+
switch (config.component) {
|
|
227
|
+
case "hero":
|
|
228
|
+
return (
|
|
229
|
+
<section key={key} className="my-12 grid gap-8 py-8 md:grid-cols-[1.2fr_0.8fr] md:items-center">
|
|
230
|
+
<div className="space-y-5">
|
|
231
|
+
{config.eyebrow ? (
|
|
232
|
+
<p className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
|
233
|
+
{config.eyebrow}
|
|
234
|
+
</p>
|
|
235
|
+
) : null}
|
|
236
|
+
{title ? (
|
|
237
|
+
<h2 className="text-4xl font-bold leading-tight tracking-tight md:text-5xl">
|
|
238
|
+
{title}
|
|
239
|
+
</h2>
|
|
240
|
+
) : null}
|
|
241
|
+
{description ? (
|
|
242
|
+
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
|
243
|
+
{description}
|
|
244
|
+
</p>
|
|
245
|
+
) : null}
|
|
246
|
+
{primaryHref && primaryLabel ? (
|
|
247
|
+
<div className="flex flex-wrap gap-3">
|
|
248
|
+
<Button asChild size="lg">
|
|
249
|
+
<a href={primaryHref}>{primaryLabel}</a>
|
|
250
|
+
</Button>
|
|
251
|
+
{secondaryHref && config.secondaryAction?.label ? (
|
|
252
|
+
<Button asChild variant="outline" size="lg">
|
|
253
|
+
<a href={secondaryHref}>{config.secondaryAction.label}</a>
|
|
254
|
+
</Button>
|
|
255
|
+
) : null}
|
|
256
|
+
</div>
|
|
257
|
+
) : null}
|
|
258
|
+
</div>
|
|
259
|
+
<div className="min-h-64 rounded-lg border bg-muted" />
|
|
260
|
+
</section>
|
|
261
|
+
);
|
|
262
|
+
case "cta":
|
|
263
|
+
return (
|
|
264
|
+
<section key={key} className="my-10 rounded-lg border bg-muted/40 p-8">
|
|
265
|
+
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
|
266
|
+
<div className="space-y-2">
|
|
267
|
+
{title ? <h2 className="text-2xl font-semibold tracking-tight">{title}</h2> : null}
|
|
268
|
+
{description ? <p className="text-muted-foreground">{description}</p> : null}
|
|
269
|
+
</div>
|
|
270
|
+
{primaryHref && primaryLabel ? (
|
|
271
|
+
<Button asChild>
|
|
272
|
+
<a href={primaryHref}>{primaryLabel}</a>
|
|
273
|
+
</Button>
|
|
274
|
+
) : null}
|
|
275
|
+
</div>
|
|
276
|
+
</section>
|
|
277
|
+
);
|
|
278
|
+
case "feature-grid":
|
|
279
|
+
return (
|
|
280
|
+
<section key={key} className="my-10 space-y-6">
|
|
281
|
+
{title ? <h2 className="text-3xl font-semibold tracking-tight">{title}</h2> : null}
|
|
282
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
283
|
+
{(config.items ?? []).map((item, index) => (
|
|
284
|
+
<div key={index} className="rounded-lg border bg-card p-5 text-card-foreground">
|
|
285
|
+
{item.title ? <h3 className="font-semibold">{item.title}</h3> : null}
|
|
286
|
+
{item.description ? (
|
|
287
|
+
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
|
288
|
+
{item.description}
|
|
289
|
+
</p>
|
|
290
|
+
) : null}
|
|
291
|
+
</div>
|
|
292
|
+
))}
|
|
293
|
+
</div>
|
|
294
|
+
</section>
|
|
295
|
+
);
|
|
296
|
+
case "pricing":
|
|
297
|
+
return (
|
|
298
|
+
<section key={key} className="my-10 space-y-6">
|
|
299
|
+
{title ? <h2 className="text-3xl font-semibold tracking-tight">{title}</h2> : null}
|
|
300
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
301
|
+
{(config.plans ?? []).map((plan, index) => {
|
|
302
|
+
const href = safeHref(plan.href);
|
|
303
|
+
return (
|
|
304
|
+
<div key={index} className="rounded-lg border bg-card p-6 text-card-foreground">
|
|
305
|
+
{plan.title ? <h3 className="text-lg font-semibold">{plan.title}</h3> : null}
|
|
306
|
+
{plan.price ? <p className="mt-4 text-3xl font-bold">{plan.price}</p> : null}
|
|
307
|
+
{plan.description ? (
|
|
308
|
+
<p className="mt-2 text-sm text-muted-foreground">{plan.description}</p>
|
|
309
|
+
) : null}
|
|
310
|
+
{plan.features?.length ? (
|
|
311
|
+
<ul className="mt-5 space-y-2 text-sm text-muted-foreground">
|
|
312
|
+
{plan.features.map((feature) => (
|
|
313
|
+
<li key={feature}>{feature}</li>
|
|
314
|
+
))}
|
|
315
|
+
</ul>
|
|
316
|
+
) : null}
|
|
317
|
+
{href ? (
|
|
318
|
+
<Button asChild className="mt-6 w-full">
|
|
319
|
+
<a href={href}>{plan.title ?? "Select"}</a>
|
|
320
|
+
</Button>
|
|
321
|
+
) : null}
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
})}
|
|
325
|
+
</div>
|
|
326
|
+
</section>
|
|
327
|
+
);
|
|
328
|
+
case "faq":
|
|
329
|
+
return (
|
|
330
|
+
<section key={key} className="my-10 space-y-4">
|
|
331
|
+
{title ? <h2 className="text-3xl font-semibold tracking-tight">{title}</h2> : null}
|
|
332
|
+
<div className="divide-y rounded-lg border">
|
|
333
|
+
{(config.questions ?? []).map((item, index) => (
|
|
334
|
+
<details key={index} className="group p-4">
|
|
335
|
+
<summary className="cursor-pointer font-medium">{item.question}</summary>
|
|
336
|
+
{item.answer ? (
|
|
337
|
+
<p className="mt-3 leading-7 text-muted-foreground">{item.answer}</p>
|
|
338
|
+
) : null}
|
|
339
|
+
</details>
|
|
340
|
+
))}
|
|
341
|
+
</div>
|
|
342
|
+
</section>
|
|
343
|
+
);
|
|
344
|
+
case "testimonial-grid":
|
|
345
|
+
return (
|
|
346
|
+
<section key={key} className="my-10 grid gap-4 md:grid-cols-3">
|
|
347
|
+
{(config.testimonials ?? []).map((item, index) => (
|
|
348
|
+
<figure key={index} className="rounded-lg border bg-card p-5 text-card-foreground">
|
|
349
|
+
{item.quote ? <blockquote className="leading-7">{item.quote}</blockquote> : null}
|
|
350
|
+
{(item.author || item.role) ? (
|
|
351
|
+
<figcaption className="mt-4 text-sm text-muted-foreground">
|
|
352
|
+
{[item.author, item.role].filter(Boolean).join(" - ")}
|
|
353
|
+
</figcaption>
|
|
354
|
+
) : null}
|
|
355
|
+
</figure>
|
|
356
|
+
))}
|
|
357
|
+
</section>
|
|
358
|
+
);
|
|
359
|
+
case "content-list":
|
|
360
|
+
return (
|
|
361
|
+
<section key={key} className="my-10 rounded-lg border p-6">
|
|
362
|
+
{title ? <h2 className="text-2xl font-semibold tracking-tight">{title}</h2> : null}
|
|
363
|
+
{description ? <p className="mt-2 text-muted-foreground">{description}</p> : null}
|
|
364
|
+
</section>
|
|
365
|
+
);
|
|
366
|
+
case "contact-form":
|
|
367
|
+
return (
|
|
368
|
+
<section key={key} className="my-10 rounded-lg border bg-card p-6 text-card-foreground">
|
|
369
|
+
{title ? <h2 className="text-2xl font-semibold tracking-tight">{title}</h2> : null}
|
|
370
|
+
{description ? <p className="mt-2 text-muted-foreground">{description}</p> : null}
|
|
371
|
+
{primaryHref && primaryLabel ? (
|
|
372
|
+
<Button asChild className="mt-5">
|
|
373
|
+
<a href={primaryHref}>{primaryLabel}</a>
|
|
374
|
+
</Button>
|
|
375
|
+
) : null}
|
|
376
|
+
</section>
|
|
377
|
+
);
|
|
378
|
+
default:
|
|
379
|
+
return renderDevFallback(key, `Unsupported vinext component: ${config.component ?? "unknown"}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function parseCustomConfig(codeText: string): CustomConfig | null {
|
|
384
|
+
try {
|
|
385
|
+
const parsed = JSON.parse(codeText) as unknown;
|
|
386
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
387
|
+
const config = parsed as CustomConfig;
|
|
388
|
+
return typeof config.component === "string" ? config : null;
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function renderTable(block: NotionBlock): ReactNode {
|
|
395
|
+
const value = typedValue(block);
|
|
396
|
+
const rows = (block.children ?? []).filter((child) => child.type === "table_row");
|
|
397
|
+
if (!rows.length) return null;
|
|
398
|
+
const hasColumnHeader = Boolean(value.has_column_header);
|
|
399
|
+
const hasRowHeader = Boolean(value.has_row_header);
|
|
400
|
+
|
|
401
|
+
function renderRow(row: NotionBlock, rowIndex: number) {
|
|
402
|
+
const rowValue = typedValue(row);
|
|
403
|
+
const cells = rowValue.cells ?? [];
|
|
404
|
+
const RowTag = hasColumnHeader && rowIndex === 0 ? "tr" : "tr";
|
|
405
|
+
return (
|
|
406
|
+
<RowTag key={row.id} className="border-b last:border-b-0">
|
|
407
|
+
{cells.map((cell, cellIndex) => {
|
|
408
|
+
const isHeader =
|
|
409
|
+
(hasColumnHeader && rowIndex === 0) || (hasRowHeader && cellIndex === 0);
|
|
410
|
+
const CellTag = isHeader ? "th" : "td";
|
|
411
|
+
return (
|
|
412
|
+
<CellTag
|
|
413
|
+
key={cellIndex}
|
|
414
|
+
className={cn(
|
|
415
|
+
"min-w-32 px-4 py-3 align-top text-sm leading-6",
|
|
416
|
+
isHeader ? "bg-muted/40 text-left font-medium" : "text-foreground/90"
|
|
417
|
+
)}
|
|
418
|
+
scope={isHeader ? "col" : undefined}
|
|
419
|
+
>
|
|
420
|
+
{renderRichText(cell)}
|
|
421
|
+
</CellTag>
|
|
422
|
+
);
|
|
423
|
+
})}
|
|
424
|
+
</RowTag>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
<div key={block.id} className="my-8 overflow-x-auto rounded-lg border">
|
|
430
|
+
<table className="w-full border-collapse text-sm">
|
|
431
|
+
{hasColumnHeader ? <thead>{renderRow(rows[0], 0)}</thead> : null}
|
|
432
|
+
<tbody>{rows.slice(hasColumnHeader ? 1 : 0).map((row, idx) => renderRow(row, idx + (hasColumnHeader ? 1 : 0)))}</tbody>
|
|
433
|
+
</table>
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function renderColumns(block: NotionBlock): ReactNode {
|
|
439
|
+
const columns = block.children?.filter((child) => child.type === "column") ?? [];
|
|
440
|
+
if (!columns.length) return null;
|
|
441
|
+
const gridClass =
|
|
442
|
+
columns.length >= 3
|
|
443
|
+
? "md:grid-cols-3"
|
|
444
|
+
: columns.length === 2
|
|
445
|
+
? "md:grid-cols-2"
|
|
446
|
+
: "md:grid-cols-1";
|
|
447
|
+
return (
|
|
448
|
+
<div key={block.id} className={cn("my-8 grid gap-6", gridClass)}>
|
|
449
|
+
{columns.map((column) => (
|
|
450
|
+
<div key={column.id} className="min-w-0">
|
|
451
|
+
<NotionBlocks blocks={column.children ?? []} />
|
|
452
|
+
</div>
|
|
453
|
+
))}
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function renderDevFallback(key: string, label: string): ReactNode {
|
|
459
|
+
if (process.env.NODE_ENV === "production") return null;
|
|
460
|
+
return (
|
|
461
|
+
<div key={key} className="my-4 rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
|
462
|
+
{label}
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function renderBlock(block: NotionBlock): ReactNode {
|
|
468
|
+
const value = typedValue(block);
|
|
469
|
+
const colorClass = notionColorClass(value.color);
|
|
470
|
+
|
|
471
|
+
switch (block.type) {
|
|
472
|
+
case "paragraph": {
|
|
473
|
+
const text = renderRichText(value.rich_text);
|
|
474
|
+
if (!text) return <div key={block.id} className="h-3" />;
|
|
475
|
+
return (
|
|
476
|
+
<p key={block.id} className={cn("my-4 leading-7 text-foreground/90", colorClass)}>
|
|
477
|
+
{text}
|
|
478
|
+
</p>
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
case "heading_1":
|
|
482
|
+
return (
|
|
483
|
+
<h2 key={block.id} className={cn("mt-12 mb-4 text-3xl font-semibold tracking-tight", colorClass)}>
|
|
484
|
+
{renderRichText(value.rich_text)}
|
|
485
|
+
</h2>
|
|
486
|
+
);
|
|
487
|
+
case "heading_2":
|
|
488
|
+
return (
|
|
489
|
+
<h2 key={block.id} className={cn("mt-10 mb-4 text-2xl font-semibold tracking-tight", colorClass)}>
|
|
490
|
+
{renderRichText(value.rich_text)}
|
|
491
|
+
</h2>
|
|
492
|
+
);
|
|
493
|
+
case "heading_3":
|
|
494
|
+
return (
|
|
495
|
+
<h3 key={block.id} className={cn("mt-8 mb-3 text-xl font-semibold tracking-tight", colorClass)}>
|
|
496
|
+
{renderRichText(value.rich_text)}
|
|
497
|
+
</h3>
|
|
498
|
+
);
|
|
499
|
+
case "bulleted_list_item":
|
|
500
|
+
case "numbered_list_item":
|
|
501
|
+
return renderListGroup({
|
|
502
|
+
kind: "list",
|
|
503
|
+
ordered: block.type === "numbered_list_item",
|
|
504
|
+
blocks: [block],
|
|
505
|
+
});
|
|
506
|
+
case "to_do":
|
|
507
|
+
return (
|
|
508
|
+
<div key={block.id} className="my-3 flex items-start gap-3 leading-7 text-foreground/90">
|
|
509
|
+
<span
|
|
510
|
+
aria-hidden="true"
|
|
511
|
+
className={cn(
|
|
512
|
+
"mt-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border",
|
|
513
|
+
value.checked && "border-primary bg-primary text-primary-foreground"
|
|
514
|
+
)}
|
|
515
|
+
>
|
|
516
|
+
{value.checked ? "✓" : null}
|
|
517
|
+
</span>
|
|
518
|
+
<div>
|
|
519
|
+
{renderRichText(value.rich_text)}
|
|
520
|
+
{block.children?.length ? <NotionBlocks blocks={block.children} /> : null}
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
case "quote":
|
|
525
|
+
return (
|
|
526
|
+
<blockquote key={block.id} className={cn("my-6 border-l-4 border-muted-foreground/30 pl-4 italic leading-7 text-muted-foreground", colorClass)}>
|
|
527
|
+
{renderRichText(value.rich_text)}
|
|
528
|
+
</blockquote>
|
|
529
|
+
);
|
|
530
|
+
case "callout":
|
|
531
|
+
return (
|
|
532
|
+
<div key={block.id} className={cn("my-6 flex gap-3 rounded-lg border bg-muted/40 p-4", colorClass)}>
|
|
533
|
+
{value.icon?.emoji ? <div className="text-xl leading-none">{value.icon.emoji}</div> : null}
|
|
534
|
+
<div className="min-w-0 flex-1 leading-7">
|
|
535
|
+
{renderRichText(value.rich_text)}
|
|
536
|
+
{block.children?.length ? <NotionBlocks blocks={block.children} /> : null}
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
);
|
|
540
|
+
case "divider":
|
|
541
|
+
return <hr key={block.id} className="my-8 border-muted-foreground/20" />;
|
|
542
|
+
case "code": {
|
|
543
|
+
const codeText = plainText(value.rich_text);
|
|
544
|
+
const language = value.language ?? "text";
|
|
545
|
+
if (language === "vinext") {
|
|
546
|
+
const config = parseCustomConfig(codeText);
|
|
547
|
+
if (config) return renderCustomComponent(config, block.id);
|
|
548
|
+
}
|
|
549
|
+
return (
|
|
550
|
+
<pre key={block.id} className="my-6 overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-50">
|
|
551
|
+
<code data-language={language} className="font-mono">
|
|
552
|
+
{codeText}
|
|
553
|
+
</code>
|
|
554
|
+
</pre>
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
case "image": {
|
|
558
|
+
const url = readBlockFileUrl(value);
|
|
559
|
+
const caption = plainText(value.caption);
|
|
560
|
+
if (!url) return null;
|
|
561
|
+
return (
|
|
562
|
+
<figure key={block.id} className="my-8 space-y-2">
|
|
563
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
564
|
+
<img src={url} alt={caption || ""} className="w-full rounded-lg border bg-muted" loading="lazy" />
|
|
565
|
+
{caption ? <figcaption className="text-center text-sm text-muted-foreground">{caption}</figcaption> : null}
|
|
566
|
+
</figure>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
case "video": {
|
|
570
|
+
const url = readBlockFileUrl(value);
|
|
571
|
+
if (!url) return null;
|
|
572
|
+
return (
|
|
573
|
+
<div key={block.id} className="my-8 overflow-hidden rounded-lg border bg-muted">
|
|
574
|
+
<video controls src={url} className="aspect-video w-full" />
|
|
575
|
+
</div>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
case "audio": {
|
|
579
|
+
const url = readBlockFileUrl(value);
|
|
580
|
+
if (!url) return null;
|
|
581
|
+
return <audio key={block.id} controls src={url} className="my-6 w-full" />;
|
|
582
|
+
}
|
|
583
|
+
case "pdf":
|
|
584
|
+
case "file": {
|
|
585
|
+
const url = readBlockFileUrl(value);
|
|
586
|
+
const caption = plainText(value.caption) || url;
|
|
587
|
+
if (!url) return null;
|
|
588
|
+
return (
|
|
589
|
+
<a key={block.id} href={url} target="_blank" rel="noreferrer noopener" className="my-4 block rounded-md border bg-muted/40 px-4 py-3 text-sm hover:bg-muted">
|
|
590
|
+
{caption}
|
|
591
|
+
</a>
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
case "embed":
|
|
595
|
+
case "bookmark":
|
|
596
|
+
case "link_preview": {
|
|
597
|
+
const url = safeHref(value.url);
|
|
598
|
+
if (!url) return null;
|
|
599
|
+
return (
|
|
600
|
+
<a key={block.id} href={url} target={url.startsWith("/") ? undefined : "_blank"} rel={url.startsWith("/") ? undefined : "noreferrer noopener"} className="my-4 block rounded-lg border bg-card px-4 py-3 text-card-foreground transition-colors hover:bg-muted/60">
|
|
601
|
+
<span className="block truncate text-sm font-medium">{url}</span>
|
|
602
|
+
</a>
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
case "toggle":
|
|
606
|
+
return (
|
|
607
|
+
<details key={block.id} className="my-4 rounded-lg border bg-muted/30 px-4 py-3">
|
|
608
|
+
<summary className="cursor-pointer font-medium leading-7">
|
|
609
|
+
{renderRichText(value.rich_text)}
|
|
610
|
+
</summary>
|
|
611
|
+
{block.children?.length ? <div className="mt-3"><NotionBlocks blocks={block.children} /></div> : null}
|
|
612
|
+
</details>
|
|
613
|
+
);
|
|
614
|
+
case "table":
|
|
615
|
+
return renderTable(block);
|
|
616
|
+
case "column_list":
|
|
617
|
+
return renderColumns(block);
|
|
618
|
+
case "column":
|
|
619
|
+
return <NotionBlocks key={block.id} blocks={block.children ?? []} />;
|
|
620
|
+
case "child_page": {
|
|
621
|
+
const title = plainText(value.title) || (value as { title?: string }).title || "Untitled";
|
|
622
|
+
return (
|
|
623
|
+
<div key={block.id} className="my-4 rounded-lg border bg-card px-4 py-3 text-card-foreground">
|
|
624
|
+
<span className="font-medium">{title}</span>
|
|
625
|
+
</div>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
case "button": {
|
|
629
|
+
const label = plainText(value.rich_text ?? value.title) || (value as { name?: string }).name;
|
|
630
|
+
const href = safeHref(value.url ?? (value as { href?: string }).href);
|
|
631
|
+
if (!label || !href) return renderDevFallback(block.id, "Unsupported Notion button action");
|
|
632
|
+
return (
|
|
633
|
+
<div key={block.id} className="my-5">
|
|
634
|
+
<Button asChild>
|
|
635
|
+
<a href={href}>{label}</a>
|
|
636
|
+
</Button>
|
|
637
|
+
</div>
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
case "equation":
|
|
641
|
+
return (
|
|
642
|
+
<div key={block.id} className="my-4 overflow-x-auto rounded-md bg-muted px-4 py-3 font-mono text-sm">
|
|
643
|
+
{value.expression}
|
|
644
|
+
</div>
|
|
645
|
+
);
|
|
646
|
+
case "synced_block":
|
|
647
|
+
return block.children?.length ? <NotionBlocks key={block.id} blocks={block.children} /> : null;
|
|
648
|
+
case "child_database":
|
|
649
|
+
return renderDevFallback(block.id, "child_database needs an explicit content-list mapping");
|
|
650
|
+
case "table_row":
|
|
651
|
+
return null;
|
|
652
|
+
default:
|
|
653
|
+
return renderDevFallback(block.id, `Unsupported Notion block: ${block.type}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function NotionBlocks({ blocks }: { blocks: NotionBlock[] }) {
|
|
658
|
+
if (!blocks || blocks.length === 0) return null;
|
|
659
|
+
const groups = groupBlocks(blocks);
|
|
660
|
+
return (
|
|
661
|
+
<div className="max-w-none">
|
|
662
|
+
{groups.map((group) =>
|
|
663
|
+
group.kind === "list" ? renderListGroup(group) : renderBlock(group.block)
|
|
664
|
+
)}
|
|
665
|
+
</div>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|