@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,18 @@
|
|
|
1
|
+
// Public API: list items for the {{contentSourceTitle}} content source.
|
|
2
|
+
// Returns a JSON array of published items with their metadata.
|
|
3
|
+
|
|
4
|
+
import { NextResponse } from "next/server";
|
|
5
|
+
import { listGenericNotionContent } from "@notionx/core/notion";
|
|
6
|
+
import { {{contentSourceVarName}} } from "@/lib/content/models";
|
|
7
|
+
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
9
|
+
|
|
10
|
+
export async function GET() {
|
|
11
|
+
try {
|
|
12
|
+
const items = await listGenericNotionContent({{contentSourceVarName}});
|
|
13
|
+
return NextResponse.json({ items });
|
|
14
|
+
} catch (err) {
|
|
15
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
16
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@plugin "tailwindcss-animate";
|
|
3
|
+
|
|
4
|
+
@custom-variant dark (&:is(.dark *));
|
|
5
|
+
|
|
6
|
+
/* =========================================================================
|
|
7
|
+
Design tokens
|
|
8
|
+
=========================================================================
|
|
9
|
+
These CSS variables are the single source of truth for the entire UI.
|
|
10
|
+
Every shadcn primitive (Button, Card, Badge, Input, ...) reads from
|
|
11
|
+
them via `bg-primary`, `text-foreground`, `rounded-md`, etc.
|
|
12
|
+
|
|
13
|
+
To re-skin the project, just edit the values below. The default is
|
|
14
|
+
neutral black/white (the shadcn "new-york" base). See README →
|
|
15
|
+
"Customize the look" for recipes.
|
|
16
|
+
========================================================================= */
|
|
17
|
+
@layer base {
|
|
18
|
+
:root {
|
|
19
|
+
/* Surfaces */
|
|
20
|
+
--background: 0 0% 100%;
|
|
21
|
+
--foreground: 0 0% 3.9%;
|
|
22
|
+
|
|
23
|
+
/* Card / popover surfaces */
|
|
24
|
+
--card: 0 0% 100%;
|
|
25
|
+
--card-foreground: 0 0% 3.9%;
|
|
26
|
+
--popover: 0 0% 100%;
|
|
27
|
+
--popover-foreground: 0 0% 3.9%;
|
|
28
|
+
|
|
29
|
+
/* Brand — the "1 color to rule them all" */
|
|
30
|
+
--primary: 0 0% 9%;
|
|
31
|
+
--primary-foreground: 0 0% 98%;
|
|
32
|
+
|
|
33
|
+
/* Muted / accent */
|
|
34
|
+
--secondary: 0 0% 96.1%;
|
|
35
|
+
--secondary-foreground: 0 0% 9%;
|
|
36
|
+
--muted: 0 0% 96.1%;
|
|
37
|
+
--muted-foreground: 0 0% 45.1%;
|
|
38
|
+
--accent: 0 0% 96.1%;
|
|
39
|
+
--accent-foreground: 0 0% 9%;
|
|
40
|
+
|
|
41
|
+
/* Status */
|
|
42
|
+
--destructive: 0 84.2% 60.2%;
|
|
43
|
+
--destructive-foreground: 0 0% 98%;
|
|
44
|
+
|
|
45
|
+
/* Form controls */
|
|
46
|
+
--border: 0 0% 89.8%;
|
|
47
|
+
--input: 0 0% 89.8%;
|
|
48
|
+
--ring: 0 0% 3.9%;
|
|
49
|
+
|
|
50
|
+
/* Geometry */
|
|
51
|
+
--radius: 0.5rem;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.dark {
|
|
55
|
+
--background: 0 0% 3.9%;
|
|
56
|
+
--foreground: 0 0% 98%;
|
|
57
|
+
--card: 0 0% 3.9%;
|
|
58
|
+
--card-foreground: 0 0% 98%;
|
|
59
|
+
--popover: 0 0% 3.9%;
|
|
60
|
+
--popover-foreground: 0 0% 98%;
|
|
61
|
+
--primary: 0 0% 98%;
|
|
62
|
+
--primary-foreground: 0 0% 9%;
|
|
63
|
+
--secondary: 0 0% 14.9%;
|
|
64
|
+
--secondary-foreground: 0 0% 98%;
|
|
65
|
+
--muted: 0 0% 14.9%;
|
|
66
|
+
--muted-foreground: 0 0% 63.9%;
|
|
67
|
+
--accent: 0 0% 14.9%;
|
|
68
|
+
--accent-foreground: 0 0% 98%;
|
|
69
|
+
--destructive: 0 62.8% 30.6%;
|
|
70
|
+
--destructive-foreground: 0 0% 98%;
|
|
71
|
+
--border: 0 0% 14.9%;
|
|
72
|
+
--input: 0 0% 14.9%;
|
|
73
|
+
--ring: 0 0% 83.1%;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@theme inline {
|
|
78
|
+
--color-background: hsl(var(--background));
|
|
79
|
+
--color-foreground: hsl(var(--foreground));
|
|
80
|
+
--color-card: hsl(var(--card));
|
|
81
|
+
--color-card-foreground: hsl(var(--card-foreground));
|
|
82
|
+
--color-popover: hsl(var(--popover));
|
|
83
|
+
--color-popover-foreground: hsl(var(--popover-foreground));
|
|
84
|
+
--color-primary: hsl(var(--primary));
|
|
85
|
+
--color-primary-foreground: hsl(var(--primary-foreground));
|
|
86
|
+
--color-secondary: hsl(var(--secondary));
|
|
87
|
+
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
|
88
|
+
--color-muted: hsl(var(--muted));
|
|
89
|
+
--color-muted-foreground: hsl(var(--muted-foreground));
|
|
90
|
+
--color-accent: hsl(var(--accent));
|
|
91
|
+
--color-accent-foreground: hsl(var(--accent-foreground));
|
|
92
|
+
--color-destructive: hsl(var(--destructive));
|
|
93
|
+
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
|
94
|
+
--color-border: hsl(var(--border));
|
|
95
|
+
--color-input: hsl(var(--input));
|
|
96
|
+
--color-ring: hsl(var(--ring));
|
|
97
|
+
--radius-lg: var(--radius);
|
|
98
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
99
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@layer base {
|
|
103
|
+
* {
|
|
104
|
+
@apply border-border;
|
|
105
|
+
}
|
|
106
|
+
body {
|
|
107
|
+
@apply bg-background text-foreground;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import "./globals.css";
|
|
3
|
+
import { ThemeProvider } from "@/components/theme-provider";
|
|
4
|
+
import { ThemeBootstrap } from "@/components/site/theme-bootstrap";
|
|
5
|
+
import { getSiteSettings, getStaticSiteSettings } from "@/lib/site/settings";
|
|
6
|
+
import { fallbackSiteConfig } from "@/lib/site/config";
|
|
7
|
+
|
|
8
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
9
|
+
const settings = await getSiteSettings();
|
|
10
|
+
return {
|
|
11
|
+
title: {
|
|
12
|
+
default: settings.seo.title || settings.name,
|
|
13
|
+
template: `%s · ${settings.seo.title || settings.name}`,
|
|
14
|
+
},
|
|
15
|
+
description: settings.seo.description || settings.description,
|
|
16
|
+
openGraph: {
|
|
17
|
+
title: settings.seo.title || settings.name,
|
|
18
|
+
description: settings.seo.description || settings.description,
|
|
19
|
+
...(settings.ogImageUrl
|
|
20
|
+
? { images: [{ url: settings.ogImageUrl }] }
|
|
21
|
+
: settings.socialImageUrl
|
|
22
|
+
? { images: [{ url: settings.socialImageUrl }] }
|
|
23
|
+
: {}),
|
|
24
|
+
type: "website",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default async function RootLayout({
|
|
30
|
+
children,
|
|
31
|
+
}: Readonly<{
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
}>) {
|
|
34
|
+
const settings = (await getSiteSettings()) ?? getStaticSiteSettings();
|
|
35
|
+
return (
|
|
36
|
+
<html
|
|
37
|
+
lang={settings.defaultLocale ?? fallbackSiteConfig.defaultLocale}
|
|
38
|
+
data-theme-primary={settings.theme.primary}
|
|
39
|
+
data-theme-accent={settings.theme.accent}
|
|
40
|
+
data-theme-font={settings.theme.font}
|
|
41
|
+
suppressHydrationWarning
|
|
42
|
+
>
|
|
43
|
+
<body className="min-h-screen bg-background font-sans antialiased">
|
|
44
|
+
<ThemeBootstrap />
|
|
45
|
+
<ThemeProvider
|
|
46
|
+
attribute="class"
|
|
47
|
+
defaultTheme="system"
|
|
48
|
+
enableSystem
|
|
49
|
+
disableTransitionOnChange
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</ThemeProvider>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Sign-in page. The `emailLoginAction` server action authenticates
|
|
2
|
+
// against the D1-backed user table exposed by `@notionx/core/auth`,
|
|
3
|
+
// rate-limits by email + IP, and sets the HMAC-signed user session
|
|
4
|
+
// cookie on success. The form is shadcn-styled and inherits the
|
|
5
|
+
// project's design tokens from `app/globals.css`.
|
|
6
|
+
|
|
7
|
+
import Link from "next/link";
|
|
8
|
+
import { redirect } from "next/navigation";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Label } from "@/components/ui/label";
|
|
12
|
+
import {
|
|
13
|
+
Card,
|
|
14
|
+
CardContent,
|
|
15
|
+
CardDescription,
|
|
16
|
+
CardHeader,
|
|
17
|
+
CardTitle,
|
|
18
|
+
} from "@/components/ui/card";
|
|
19
|
+
import { siteConfig } from "@/lib/site/config";
|
|
20
|
+
import {
|
|
21
|
+
authenticateEmailUser,
|
|
22
|
+
clearAuthRateLimits,
|
|
23
|
+
enforceAuthRateLimits,
|
|
24
|
+
isAuthenticated,
|
|
25
|
+
recordAuthFailures,
|
|
26
|
+
setUserSessionCookie,
|
|
27
|
+
userToSession,
|
|
28
|
+
} from "@notionx/core/auth";
|
|
29
|
+
import { getClientIp } from "@notionx/core/util";
|
|
30
|
+
|
|
31
|
+
export const dynamic = "force-dynamic";
|
|
32
|
+
|
|
33
|
+
type LoginError = "invalid" | "unverified" | "rate" | "captcha";
|
|
34
|
+
|
|
35
|
+
function loginRedirect(error: LoginError, email?: string): never {
|
|
36
|
+
const params = new URLSearchParams({ loginError: error });
|
|
37
|
+
if (email) params.set("email", email);
|
|
38
|
+
redirect(`/login?${params.toString()}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function emailLoginAction(formData: FormData): Promise<void> {
|
|
42
|
+
"use server";
|
|
43
|
+
const email = String(formData.get("email") ?? "").trim();
|
|
44
|
+
const password = String(formData.get("password") ?? "");
|
|
45
|
+
if (!email || !password) loginRedirect("invalid", email);
|
|
46
|
+
|
|
47
|
+
const ip = await getClientIp();
|
|
48
|
+
const limit = await enforceAuthRateLimits("login", { email, ip });
|
|
49
|
+
if (!limit.ok) {
|
|
50
|
+
const params = new URLSearchParams({
|
|
51
|
+
loginError: "rate",
|
|
52
|
+
retry: String(limit.retryAfterSec),
|
|
53
|
+
});
|
|
54
|
+
if (email) params.set("email", email);
|
|
55
|
+
redirect(`/login?${params.toString()}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = await authenticateEmailUser({ email, password });
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
await recordAuthFailures("login", { email, ip });
|
|
61
|
+
loginRedirect(result.reason, email);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await clearAuthRateLimits("login", { email, ip });
|
|
65
|
+
await setUserSessionCookie(userToSession(result.user));
|
|
66
|
+
redirect("/admin");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default async function LoginPage({
|
|
70
|
+
searchParams,
|
|
71
|
+
}: {
|
|
72
|
+
searchParams?: Promise<{
|
|
73
|
+
loginError?: string;
|
|
74
|
+
email?: string;
|
|
75
|
+
retry?: string;
|
|
76
|
+
registered?: string;
|
|
77
|
+
}>;
|
|
78
|
+
}) {
|
|
79
|
+
if (await isAuthenticated()) redirect("/admin");
|
|
80
|
+
const sp = (await searchParams) ?? {};
|
|
81
|
+
const emailValue = sp.email ?? "";
|
|
82
|
+
const errorMessage = (() => {
|
|
83
|
+
switch (sp.loginError) {
|
|
84
|
+
case "invalid":
|
|
85
|
+
return "邮箱或密码错误";
|
|
86
|
+
case "unverified":
|
|
87
|
+
return "邮箱还没有验证,请先打开验证邮件中的链接。";
|
|
88
|
+
case "captcha":
|
|
89
|
+
return "请完成人机验证后再试";
|
|
90
|
+
case "rate":
|
|
91
|
+
return `登录尝试过多,请 ${sp.retry ?? "900"} 秒后再试`;
|
|
92
|
+
default:
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<main className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted p-4">
|
|
99
|
+
<Card className="w-full max-w-sm">
|
|
100
|
+
<CardHeader>
|
|
101
|
+
<CardTitle className="text-2xl">Sign in</CardTitle>
|
|
102
|
+
<CardDescription>
|
|
103
|
+
Enter your admin credentials to manage {siteConfig.name}.
|
|
104
|
+
</CardDescription>
|
|
105
|
+
</CardHeader>
|
|
106
|
+
<CardContent>
|
|
107
|
+
{sp.registered ? (
|
|
108
|
+
<p className="mb-4 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-400">
|
|
109
|
+
验证邮件已发送到 {emailValue || "你的邮箱"},请先验证后再登录。
|
|
110
|
+
</p>
|
|
111
|
+
) : null}
|
|
112
|
+
{errorMessage ? (
|
|
113
|
+
<p className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
114
|
+
{errorMessage}
|
|
115
|
+
</p>
|
|
116
|
+
) : null}
|
|
117
|
+
<form action={emailLoginAction} className="space-y-4">
|
|
118
|
+
<div className="space-y-2">
|
|
119
|
+
<Label htmlFor="email">Email</Label>
|
|
120
|
+
<Input
|
|
121
|
+
id="email"
|
|
122
|
+
name="email"
|
|
123
|
+
type="email"
|
|
124
|
+
required
|
|
125
|
+
autoFocus
|
|
126
|
+
autoComplete="email"
|
|
127
|
+
defaultValue={emailValue}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
<Label htmlFor="password">Password</Label>
|
|
132
|
+
<Input
|
|
133
|
+
id="password"
|
|
134
|
+
name="password"
|
|
135
|
+
type="password"
|
|
136
|
+
required
|
|
137
|
+
autoComplete="current-password"
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
<Button type="submit" className="w-full">
|
|
141
|
+
Sign in
|
|
142
|
+
</Button>
|
|
143
|
+
</form>
|
|
144
|
+
<p className="mt-4 text-center text-xs text-muted-foreground">
|
|
145
|
+
没有账号?{" "}
|
|
146
|
+
<Link href="/register" className="underline">
|
|
147
|
+
立即注册
|
|
148
|
+
</Link>
|
|
149
|
+
</p>
|
|
150
|
+
</CardContent>
|
|
151
|
+
</Card>
|
|
152
|
+
</main>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { SiteShell } from "@/components/site/site-shell";
|
|
3
|
+
import { siteConfig } from "@/lib/site/config";
|
|
4
|
+
|
|
5
|
+
export const revalidate = 300;
|
|
6
|
+
|
|
7
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
8
|
+
return {
|
|
9
|
+
title: siteConfig.name,
|
|
10
|
+
description: siteConfig.description,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function Home() {
|
|
15
|
+
return (
|
|
16
|
+
<SiteShell showHeader={true} showFooter={true}>
|
|
17
|
+
<main>
|
|
18
|
+
<section className="border-t bg-muted/20">
|
|
19
|
+
<div className="container mx-auto max-w-3xl px-4 py-14">
|
|
20
|
+
<h1 className="text-3xl font-bold tracking-tight">
|
|
21
|
+
{siteConfig.name}
|
|
22
|
+
</h1>
|
|
23
|
+
<p className="mt-2 text-muted-foreground">
|
|
24
|
+
{siteConfig.description}
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
</section>
|
|
28
|
+
</main>
|
|
29
|
+
</SiteShell>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { NotionBlocks } from "@/components/notion-blocks";
|
|
3
|
+
import { PageBlocks } from "@/components/page-blocks";
|
|
4
|
+
import { SiteShell } from "@/components/site/site-shell";
|
|
5
|
+
import { siteConfig } from "@/lib/site/config";
|
|
6
|
+
import { getSitePageByKey } from "@/lib/pages/source";
|
|
7
|
+
|
|
8
|
+
export const revalidate = 300;
|
|
9
|
+
|
|
10
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
11
|
+
const page = await getSitePageByKey("home");
|
|
12
|
+
return {
|
|
13
|
+
title: page?.seoTitle ?? siteConfig.name,
|
|
14
|
+
description: page?.seoDescription ?? siteConfig.description,
|
|
15
|
+
openGraph: page?.coverImage
|
|
16
|
+
? { images: [{ url: page.coverImage }] }
|
|
17
|
+
: undefined,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default async function Home() {
|
|
22
|
+
const page = await getSitePageByKey("home");
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<SiteShell
|
|
26
|
+
showHeader={page?.showHeader ?? true}
|
|
27
|
+
showFooter={page?.showFooter ?? true}
|
|
28
|
+
>
|
|
29
|
+
<main>
|
|
30
|
+
{page?.structuredBlocks?.length ? (
|
|
31
|
+
<PageBlocks blocks={page.structuredBlocks} />
|
|
32
|
+
) : page?.blocks?.length ? (
|
|
33
|
+
<section className="border-t bg-muted/20">
|
|
34
|
+
<div className="container mx-auto max-w-3xl px-4 py-14">
|
|
35
|
+
<NotionBlocks blocks={page.blocks} />
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
38
|
+
) : null}
|
|
39
|
+
</main>
|
|
40
|
+
</SiteShell>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Self-service registration page. Creates a new user in D1, issues
|
|
2
|
+
// an email-verify token, and bounces the user back to /login with a
|
|
3
|
+
// `?registered=1` banner. Email delivery and the actual verify link
|
|
4
|
+
// come from the package's `auth/email` and `/api/auth/verify-email`
|
|
5
|
+
// flows; this page just produces a registration row.
|
|
6
|
+
|
|
7
|
+
import Link from "next/link";
|
|
8
|
+
import { redirect } from "next/navigation";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Label } from "@/components/ui/label";
|
|
12
|
+
import {
|
|
13
|
+
Card,
|
|
14
|
+
CardContent,
|
|
15
|
+
CardDescription,
|
|
16
|
+
CardHeader,
|
|
17
|
+
CardTitle,
|
|
18
|
+
} from "@/components/ui/card";
|
|
19
|
+
import { siteConfig } from "@/lib/site/config";
|
|
20
|
+
import {
|
|
21
|
+
createEmailUser,
|
|
22
|
+
isAuthenticated,
|
|
23
|
+
validatePasswordStrength,
|
|
24
|
+
} from "@notionx/core/auth";
|
|
25
|
+
import { getClientIp } from "@notionx/core/util";
|
|
26
|
+
|
|
27
|
+
export const dynamic = "force-dynamic";
|
|
28
|
+
|
|
29
|
+
type RegisterError = "exists" | "weak" | "missing";
|
|
30
|
+
|
|
31
|
+
function registerRedirect(error: RegisterError, email?: string): never {
|
|
32
|
+
const params = new URLSearchParams({ error });
|
|
33
|
+
if (email) params.set("email", email);
|
|
34
|
+
redirect(`/register?${params.toString()}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function registerAction(formData: FormData): Promise<void> {
|
|
38
|
+
"use server";
|
|
39
|
+
const email = String(formData.get("email") ?? "").trim();
|
|
40
|
+
const password = String(formData.get("password") ?? "");
|
|
41
|
+
if (!email || !password) registerRedirect("missing", email);
|
|
42
|
+
|
|
43
|
+
const strength = validatePasswordStrength(password);
|
|
44
|
+
if (strength) registerRedirect("weak", email);
|
|
45
|
+
|
|
46
|
+
const ip = await getClientIp();
|
|
47
|
+
void ip; // Reserved for future rate limiting on the register flow.
|
|
48
|
+
|
|
49
|
+
const result = await createEmailUser({ email, password });
|
|
50
|
+
if (!result.ok) registerRedirect("exists", email);
|
|
51
|
+
|
|
52
|
+
// Verification email delivery is intentionally out of scope for
|
|
53
|
+
// the scaffold. The user can wire Resend via `lib/email/resend.ts`
|
|
54
|
+
// or trigger `/api/auth/verify-email?token=…` manually from the
|
|
55
|
+
// admin panel to confirm the account.
|
|
56
|
+
const params = new URLSearchParams({ registered: "1" });
|
|
57
|
+
params.set("email", email);
|
|
58
|
+
redirect(`/login?${params.toString()}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default async function RegisterPage({
|
|
62
|
+
searchParams,
|
|
63
|
+
}: {
|
|
64
|
+
searchParams?: Promise<{ error?: string; email?: string }>;
|
|
65
|
+
}) {
|
|
66
|
+
if (await isAuthenticated()) redirect("/admin");
|
|
67
|
+
const sp = (await searchParams) ?? {};
|
|
68
|
+
const emailValue = sp.email ?? "";
|
|
69
|
+
const errorMessage = (() => {
|
|
70
|
+
switch (sp.error) {
|
|
71
|
+
case "exists":
|
|
72
|
+
return "该邮箱已注册,直接登录即可。";
|
|
73
|
+
case "weak":
|
|
74
|
+
return "密码至少 8 位且需同时包含字母和数字。";
|
|
75
|
+
case "missing":
|
|
76
|
+
return "请填写邮箱和密码。";
|
|
77
|
+
default:
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<main className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted p-4">
|
|
84
|
+
<Card className="w-full max-w-sm">
|
|
85
|
+
<CardHeader>
|
|
86
|
+
<CardTitle className="text-2xl">Create account</CardTitle>
|
|
87
|
+
<CardDescription>
|
|
88
|
+
Register an admin account for {siteConfig.name}.
|
|
89
|
+
</CardDescription>
|
|
90
|
+
</CardHeader>
|
|
91
|
+
<CardContent>
|
|
92
|
+
{errorMessage ? (
|
|
93
|
+
<p className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
94
|
+
{errorMessage}
|
|
95
|
+
</p>
|
|
96
|
+
) : null}
|
|
97
|
+
<form action={registerAction} className="space-y-4">
|
|
98
|
+
<div className="space-y-2">
|
|
99
|
+
<Label htmlFor="email">Email</Label>
|
|
100
|
+
<Input
|
|
101
|
+
id="email"
|
|
102
|
+
name="email"
|
|
103
|
+
type="email"
|
|
104
|
+
required
|
|
105
|
+
autoFocus
|
|
106
|
+
autoComplete="email"
|
|
107
|
+
defaultValue={emailValue}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="space-y-2">
|
|
111
|
+
<Label htmlFor="password">Password</Label>
|
|
112
|
+
<Input
|
|
113
|
+
id="password"
|
|
114
|
+
name="password"
|
|
115
|
+
type="password"
|
|
116
|
+
required
|
|
117
|
+
autoComplete="new-password"
|
|
118
|
+
minLength={8}
|
|
119
|
+
/>
|
|
120
|
+
<p className="text-xs text-muted-foreground">
|
|
121
|
+
至少 8 位,需同时包含字母和数字。
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
<Button type="submit" className="w-full">
|
|
125
|
+
Create account
|
|
126
|
+
</Button>
|
|
127
|
+
</form>
|
|
128
|
+
<p className="mt-4 text-center text-xs text-muted-foreground">
|
|
129
|
+
已有账号?{" "}
|
|
130
|
+
<Link href="/login" className="underline">
|
|
131
|
+
去登录
|
|
132
|
+
</Link>
|
|
133
|
+
</p>
|
|
134
|
+
</CardContent>
|
|
135
|
+
</Card>
|
|
136
|
+
</main>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Public detail page for the first content source (default: blog).
|
|
2
|
+
// Resolves a single Notion page by slug and renders its metadata
|
|
3
|
+
// header + body content. Uses `notFound()` so the App Router emits
|
|
4
|
+
// a 404 for unpublished, missing, or invalid slugs.
|
|
5
|
+
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
import Image from "next/image";
|
|
8
|
+
import { notFound } from "next/navigation";
|
|
9
|
+
import type { Metadata } from "next";
|
|
10
|
+
import { getGenericNotionContentBySlug } from "@notionx/core/notion";
|
|
11
|
+
import { {{contentSourceVarName}} } from "@/lib/content/models";
|
|
12
|
+
import { Badge } from "@/components/ui/badge";
|
|
13
|
+
import { NotionBlocks } from "@/components/notion-blocks";
|
|
14
|
+
import { SiteShell } from "@/components/site/site-shell";
|
|
15
|
+
import { getSitePageForContentSource } from "@/lib/pages/source";
|
|
16
|
+
|
|
17
|
+
export const revalidate = 3600;
|
|
18
|
+
|
|
19
|
+
type Params = { slug: string };
|
|
20
|
+
|
|
21
|
+
function formatDate(value: string): string {
|
|
22
|
+
if (!value) return "";
|
|
23
|
+
const date = new Date(value);
|
|
24
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
25
|
+
return date.toLocaleDateString("en-US", {
|
|
26
|
+
year: "numeric",
|
|
27
|
+
month: "short",
|
|
28
|
+
day: "numeric",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function generateMetadata({
|
|
33
|
+
params,
|
|
34
|
+
}: {
|
|
35
|
+
params: Promise<Params>;
|
|
36
|
+
}): Promise<Metadata> {
|
|
37
|
+
const { slug } = await params;
|
|
38
|
+
const item = await getGenericNotionContentBySlug({{contentSourceVarName}}, slug);
|
|
39
|
+
if (!item) return { title: "Not found" };
|
|
40
|
+
return {
|
|
41
|
+
title: item.title,
|
|
42
|
+
description: item.description,
|
|
43
|
+
openGraph: item.coverImage
|
|
44
|
+
? { images: [{ url: item.coverImage }] }
|
|
45
|
+
: undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default async function {{contentSourceConstName}}DetailPage({
|
|
50
|
+
params,
|
|
51
|
+
}: {
|
|
52
|
+
params: Promise<Params>;
|
|
53
|
+
}) {
|
|
54
|
+
const { slug } = await params;
|
|
55
|
+
const item = await getGenericNotionContentBySlug({{contentSourceVarName}}, slug);
|
|
56
|
+
const page = await getSitePageForContentSource("{{contentSourceId}}");
|
|
57
|
+
if (!item) notFound();
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<SiteShell
|
|
61
|
+
showHeader={page?.showHeader ?? true}
|
|
62
|
+
showFooter={page?.showFooter ?? true}
|
|
63
|
+
>
|
|
64
|
+
<main className="container mx-auto max-w-3xl px-4 py-16">
|
|
65
|
+
<Link
|
|
66
|
+
href="{{contentSourceListPath}}"
|
|
67
|
+
className="text-sm text-muted-foreground hover:text-foreground"
|
|
68
|
+
>
|
|
69
|
+
← Back to {page?.title || "{{contentSourceListTitle}}"}
|
|
70
|
+
</Link>
|
|
71
|
+
|
|
72
|
+
<article className="mt-6 space-y-6">
|
|
73
|
+
<header className="space-y-4">
|
|
74
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
75
|
+
<time dateTime={item.date}>{formatDate(item.date)}</time>
|
|
76
|
+
{item.tags.length > 0 ? (
|
|
77
|
+
<>
|
|
78
|
+
<span aria-hidden>·</span>
|
|
79
|
+
<div className="flex flex-wrap gap-1.5">
|
|
80
|
+
{item.tags.map((tag) => (
|
|
81
|
+
<Badge key={tag} variant="secondary">
|
|
82
|
+
{tag}
|
|
83
|
+
</Badge>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
</>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
<h1 className="text-4xl font-bold tracking-tight">{item.title}</h1>
|
|
90
|
+
{item.description ? (
|
|
91
|
+
<p className="text-lg text-muted-foreground">{item.description}</p>
|
|
92
|
+
) : null}
|
|
93
|
+
</header>
|
|
94
|
+
|
|
95
|
+
{item.coverImage ? (
|
|
96
|
+
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-lg bg-muted">
|
|
97
|
+
<Image
|
|
98
|
+
src={item.coverImage}
|
|
99
|
+
alt={item.title}
|
|
100
|
+
fill
|
|
101
|
+
sizes="(min-width: 768px) 768px, 100vw"
|
|
102
|
+
priority
|
|
103
|
+
className="object-cover"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
) : null}
|
|
107
|
+
|
|
108
|
+
<NotionBlocks blocks={item.blocks} />
|
|
109
|
+
</article>
|
|
110
|
+
</main>
|
|
111
|
+
</SiteShell>
|
|
112
|
+
);
|
|
113
|
+
}
|