@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,68 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { Badge } from "@/components/ui/badge";
|
|
3
|
+
import {
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
CardDescription,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
} from "@/components/ui/card";
|
|
10
|
+
import type { StructuredFeatureGridBlock } from "@/lib/pages/source";
|
|
11
|
+
|
|
12
|
+
function gridClassName(columns: StructuredFeatureGridBlock["columns"]) {
|
|
13
|
+
if (columns === 2) return "grid gap-6 md:grid-cols-2";
|
|
14
|
+
if (columns === 4) return "grid gap-6 md:grid-cols-2 xl:grid-cols-4";
|
|
15
|
+
return "grid gap-6 md:grid-cols-3";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function FeatureGridBlock({
|
|
19
|
+
block,
|
|
20
|
+
}: {
|
|
21
|
+
block: StructuredFeatureGridBlock;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<section className="border-t bg-background">
|
|
25
|
+
<div className="container mx-auto max-w-6xl px-4 py-14">
|
|
26
|
+
<div className="mx-auto max-w-3xl text-center">
|
|
27
|
+
<Badge variant="secondary">Feature Grid</Badge>
|
|
28
|
+
<h2 className="mt-4 text-3xl font-semibold tracking-tight md:text-4xl">
|
|
29
|
+
{block.headline}
|
|
30
|
+
</h2>
|
|
31
|
+
{block.body ? (
|
|
32
|
+
<p className="mt-4 text-lg text-muted-foreground">{block.body}</p>
|
|
33
|
+
) : null}
|
|
34
|
+
</div>
|
|
35
|
+
<div className={["mt-10", gridClassName(block.columns)].join(" ")}>
|
|
36
|
+
{block.items.map((item) => {
|
|
37
|
+
const card = (
|
|
38
|
+
<Card className="h-full">
|
|
39
|
+
<CardHeader>
|
|
40
|
+
<Badge variant="outline" className="w-fit">
|
|
41
|
+
{item.icon}
|
|
42
|
+
</Badge>
|
|
43
|
+
<CardTitle className="text-xl">{item.title}</CardTitle>
|
|
44
|
+
<CardDescription>{item.description}</CardDescription>
|
|
45
|
+
</CardHeader>
|
|
46
|
+
<CardContent>
|
|
47
|
+
{item.href ? (
|
|
48
|
+
<span className="text-sm font-medium text-primary">
|
|
49
|
+
Explore more
|
|
50
|
+
</span>
|
|
51
|
+
) : null}
|
|
52
|
+
</CardContent>
|
|
53
|
+
</Card>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return item.href ? (
|
|
57
|
+
<Link key={item.title} href={item.href} className="block">
|
|
58
|
+
{card}
|
|
59
|
+
</Link>
|
|
60
|
+
) : (
|
|
61
|
+
<div key={item.title}>{card}</div>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { Badge } from "@/components/ui/badge";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import type { StructuredHeroBlock } from "@/lib/pages/source";
|
|
5
|
+
|
|
6
|
+
function sectionClassName(theme: StructuredHeroBlock["theme"]) {
|
|
7
|
+
if (theme === "inverse") return "border-b bg-primary text-primary-foreground";
|
|
8
|
+
if (theme === "default") return "border-b bg-background";
|
|
9
|
+
return "border-b bg-muted/20";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function HeroBlock({ block }: { block: StructuredHeroBlock }) {
|
|
13
|
+
const isCentered = block.alignment === "center";
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<section className={sectionClassName(block.theme)}>
|
|
17
|
+
<div
|
|
18
|
+
className={[
|
|
19
|
+
"container mx-auto max-w-5xl px-4 py-16 md:py-24",
|
|
20
|
+
isCentered ? "text-center" : "text-left",
|
|
21
|
+
].join(" ")}
|
|
22
|
+
>
|
|
23
|
+
{block.eyebrow ? (
|
|
24
|
+
<Badge variant={block.theme === "inverse" ? "secondary" : "outline"}>
|
|
25
|
+
{block.eyebrow}
|
|
26
|
+
</Badge>
|
|
27
|
+
) : null}
|
|
28
|
+
<h2 className="mt-5 text-4xl font-semibold tracking-tight md:text-6xl">
|
|
29
|
+
{block.headline}
|
|
30
|
+
</h2>
|
|
31
|
+
{block.subheadline ? (
|
|
32
|
+
<p
|
|
33
|
+
className={[
|
|
34
|
+
"mt-5 max-w-2xl text-lg text-muted-foreground",
|
|
35
|
+
isCentered ? "mx-auto" : "",
|
|
36
|
+
block.theme === "inverse" ? "text-primary-foreground/80" : "",
|
|
37
|
+
].join(" ")}
|
|
38
|
+
>
|
|
39
|
+
{block.subheadline}
|
|
40
|
+
</p>
|
|
41
|
+
) : null}
|
|
42
|
+
{block.description ? (
|
|
43
|
+
<p
|
|
44
|
+
className={[
|
|
45
|
+
"mt-4 max-w-2xl text-sm",
|
|
46
|
+
isCentered ? "mx-auto" : "",
|
|
47
|
+
block.theme === "inverse" ? "text-primary-foreground/70" : "text-muted-foreground",
|
|
48
|
+
].join(" ")}
|
|
49
|
+
>
|
|
50
|
+
{block.description}
|
|
51
|
+
</p>
|
|
52
|
+
) : null}
|
|
53
|
+
<div
|
|
54
|
+
className={[
|
|
55
|
+
"mt-8 flex flex-wrap gap-3",
|
|
56
|
+
isCentered ? "justify-center" : "justify-start",
|
|
57
|
+
].join(" ")}
|
|
58
|
+
>
|
|
59
|
+
{block.primaryCta ? (
|
|
60
|
+
<Button asChild size="lg">
|
|
61
|
+
<Link href={block.primaryCta.href}>{block.primaryCta.label}</Link>
|
|
62
|
+
</Button>
|
|
63
|
+
) : null}
|
|
64
|
+
{block.secondaryCta ? (
|
|
65
|
+
<Button asChild size="lg" variant="secondary">
|
|
66
|
+
<Link href={block.secondaryCta.href}>{block.secondaryCta.label}</Link>
|
|
67
|
+
</Button>
|
|
68
|
+
) : null}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ArrowRight } from "lucide-react";
|
|
3
|
+
import { listGenericNotionContent } from "@notionx/core/notion";
|
|
4
|
+
import { {{contentSourceVarName}} } from "@/lib/content/models";
|
|
5
|
+
import { PostCard } from "@/components/content/post-card";
|
|
6
|
+
import { Badge } from "@/components/ui/badge";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import type { StructuredLatestPostsBlock } from "@/lib/pages/source";
|
|
9
|
+
|
|
10
|
+
export async function LatestPostsBlock({
|
|
11
|
+
block,
|
|
12
|
+
}: {
|
|
13
|
+
block: StructuredLatestPostsBlock;
|
|
14
|
+
}) {
|
|
15
|
+
const items = await listGenericNotionContent({{contentSourceVarName}});
|
|
16
|
+
const visibleItems = items.slice(0, block.count);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<section className="border-t bg-background">
|
|
20
|
+
<div className="container mx-auto max-w-6xl px-4 py-14">
|
|
21
|
+
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
22
|
+
<div className="max-w-3xl">
|
|
23
|
+
<Badge variant="secondary">Latest Posts</Badge>
|
|
24
|
+
<h2 className="mt-4 text-3xl font-semibold tracking-tight md:text-4xl">
|
|
25
|
+
{block.headline}
|
|
26
|
+
</h2>
|
|
27
|
+
{block.body ? (
|
|
28
|
+
<p className="mt-4 text-lg text-muted-foreground">{block.body}</p>
|
|
29
|
+
) : null}
|
|
30
|
+
</div>
|
|
31
|
+
{block.primaryCta ? (
|
|
32
|
+
<Button asChild variant="outline">
|
|
33
|
+
<Link href={block.primaryCta.href}>
|
|
34
|
+
{block.primaryCta.label}
|
|
35
|
+
<ArrowRight className="ml-2 h-4 w-4" />
|
|
36
|
+
</Link>
|
|
37
|
+
</Button>
|
|
38
|
+
) : null}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
{visibleItems.length ? (
|
|
42
|
+
<div className="mt-10 grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
|
43
|
+
{visibleItems.map((item) => (
|
|
44
|
+
<PostCard
|
|
45
|
+
key={item.pageId}
|
|
46
|
+
item={item}
|
|
47
|
+
href={`{{contentSourceListPath}}/${item.slug}`}
|
|
48
|
+
/>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
) : (
|
|
52
|
+
<div className="mt-10 rounded-2xl border border-dashed bg-muted/30 px-6 py-12 text-sm text-muted-foreground">
|
|
53
|
+
Publish a few posts in Notion to turn this homepage block into a live latest-posts grid.
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Badge } from "@/components/ui/badge";
|
|
2
|
+
import {
|
|
3
|
+
Card,
|
|
4
|
+
CardContent,
|
|
5
|
+
CardDescription,
|
|
6
|
+
CardHeader,
|
|
7
|
+
CardTitle,
|
|
8
|
+
} from "@/components/ui/card";
|
|
9
|
+
import type { StructuredStoryBlock } from "@/lib/pages/source";
|
|
10
|
+
|
|
11
|
+
function layoutClassName(layout: StructuredStoryBlock["layout"]) {
|
|
12
|
+
if (layout === "text-left") return "grid gap-8 lg:grid-cols-[1.2fr_0.8fr]";
|
|
13
|
+
if (layout === "media-left") return "grid gap-8 lg:grid-cols-2";
|
|
14
|
+
return "grid gap-8 lg:grid-cols-2";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function StoryBlock({ block }: { block: StructuredStoryBlock }) {
|
|
18
|
+
const mediaFirst = block.layout === "media-left";
|
|
19
|
+
|
|
20
|
+
const media = block.mediaUrl ? (
|
|
21
|
+
<div className="overflow-hidden rounded-2xl border bg-muted">
|
|
22
|
+
<img
|
|
23
|
+
src={block.mediaUrl}
|
|
24
|
+
alt={block.title}
|
|
25
|
+
className="h-full min-h-72 w-full object-cover"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
) : (
|
|
29
|
+
<Card>
|
|
30
|
+
<CardHeader>
|
|
31
|
+
<CardTitle>{block.title}</CardTitle>
|
|
32
|
+
<CardDescription>{block.description}</CardDescription>
|
|
33
|
+
</CardHeader>
|
|
34
|
+
<CardContent className="text-sm text-muted-foreground">
|
|
35
|
+
Add a media URL in the reusable block row to replace this placeholder.
|
|
36
|
+
</CardContent>
|
|
37
|
+
</Card>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const copy = (
|
|
41
|
+
<div className="space-y-5">
|
|
42
|
+
<Badge variant="outline">Story</Badge>
|
|
43
|
+
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
|
|
44
|
+
{block.headline}
|
|
45
|
+
</h2>
|
|
46
|
+
<p className="text-base leading-7 text-muted-foreground">{block.body}</p>
|
|
47
|
+
{block.quote ? (
|
|
48
|
+
<blockquote className="rounded-2xl border bg-muted/40 p-6">
|
|
49
|
+
<p className="text-lg font-medium leading-8">"{block.quote}"</p>
|
|
50
|
+
{block.quoteAttribution ? (
|
|
51
|
+
<footer className="mt-3 text-sm text-muted-foreground">
|
|
52
|
+
{block.quoteAttribution}
|
|
53
|
+
</footer>
|
|
54
|
+
) : null}
|
|
55
|
+
</blockquote>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<section className="border-t bg-muted/10">
|
|
62
|
+
<div className="container mx-auto max-w-6xl px-4 py-14">
|
|
63
|
+
<div className={layoutClassName(block.layout)}>
|
|
64
|
+
{mediaFirst ? media : copy}
|
|
65
|
+
{mediaFirst ? copy : media}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Fallback page-blocks renderer — used when the project opts out
|
|
2
|
+
// of the Notion-backed `blocks` data source
|
|
3
|
+
// (`--no-blocks` / `CREATE_NOTIONX_NO_BLOCKS=1`).
|
|
4
|
+
//
|
|
5
|
+
// `PageBlocks` is a no-op: it accepts the same `StructuredBlock[]`
|
|
6
|
+
// prop but renders nothing. Pages that reference `<PageBlocks />`
|
|
7
|
+
// keep compiling; their `structuredBlocks` field is always empty
|
|
8
|
+
// because `lib/pages/source.ts` only populates it when
|
|
9
|
+
// `blocksSource` is registered.
|
|
10
|
+
|
|
11
|
+
import type { SitePage } from "@/lib/pages/source";
|
|
12
|
+
|
|
13
|
+
type StructuredBlock = SitePage["structuredBlocks"][number];
|
|
14
|
+
|
|
15
|
+
export function PageBlocks({ blocks: _blocks }: { blocks: StructuredBlock[] }) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NotionBlocks } from "@/components/notion-blocks";
|
|
2
|
+
import type { SitePage } from "@/lib/pages/source";
|
|
3
|
+
import { FeatureGridBlock } from "@/components/page-blocks/feature-grid-block";
|
|
4
|
+
import { HeroBlock } from "@/components/page-blocks/hero-block";
|
|
5
|
+
import { LatestPostsBlock } from "@/components/page-blocks/latest-posts-block";
|
|
6
|
+
import { StoryBlock } from "@/components/page-blocks/story-block";
|
|
7
|
+
|
|
8
|
+
type StructuredBlock = SitePage["structuredBlocks"][number];
|
|
9
|
+
|
|
10
|
+
export async function PageBlocks({ blocks }: { blocks: StructuredBlock[] }) {
|
|
11
|
+
if (!blocks.length) return null;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<>
|
|
15
|
+
{blocks.map((block) => (
|
|
16
|
+
<div key={block.slug}>
|
|
17
|
+
{block.type === "hero" ? <HeroBlock block={block} /> : null}
|
|
18
|
+
{block.type === "feature-grid" ? <FeatureGridBlock block={block} /> : null}
|
|
19
|
+
{block.type === "story" ? <StoryBlock block={block} /> : null}
|
|
20
|
+
{block.type === "latest-posts" ? <LatestPostsBlock block={block} /> : null}
|
|
21
|
+
{block.type === "legacy" ? (
|
|
22
|
+
<section className="border-t bg-muted/10">
|
|
23
|
+
<div className="container mx-auto max-w-5xl px-4 py-14">
|
|
24
|
+
<NotionBlocks blocks={block.blocks} />
|
|
25
|
+
</div>
|
|
26
|
+
</section>
|
|
27
|
+
) : null}
|
|
28
|
+
</div>
|
|
29
|
+
))}
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Search dialog component. Provides a client-side search UI that
|
|
4
|
+
// queries the `/api/search` endpoint (registered by the worker when
|
|
5
|
+
// a SearchAdapter is present).
|
|
6
|
+
//
|
|
7
|
+
// Usage: place <SearchDialog /> anywhere in your layout (e.g. inside
|
|
8
|
+
// the site header). The component renders a trigger button and a
|
|
9
|
+
// modal dialog with a search input and result list.
|
|
10
|
+
//
|
|
11
|
+
// This is a user-owned file — customize freely to match your design.
|
|
12
|
+
|
|
13
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
14
|
+
import { Search, X, FileText } from "lucide-react";
|
|
15
|
+
import { Button } from "../ui/button";
|
|
16
|
+
import { Input } from "../ui/input";
|
|
17
|
+
import {
|
|
18
|
+
Dialog,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogHeader,
|
|
21
|
+
DialogTitle,
|
|
22
|
+
DialogTrigger,
|
|
23
|
+
} from "../ui/dialog";
|
|
24
|
+
|
|
25
|
+
interface SearchResult {
|
|
26
|
+
modelId: string;
|
|
27
|
+
routeId: string;
|
|
28
|
+
title: string;
|
|
29
|
+
summary: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SearchResponse {
|
|
33
|
+
results: SearchResult[];
|
|
34
|
+
query: string;
|
|
35
|
+
count: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SearchDialog() {
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
const [query, setQuery] = useState("");
|
|
41
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
42
|
+
const [loading, setLoading] = useState(false);
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
45
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
46
|
+
|
|
47
|
+
const performSearch = useCallback(async (q: string) => {
|
|
48
|
+
if (!q.trim()) {
|
|
49
|
+
setResults([]);
|
|
50
|
+
setError(null);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
setLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&limit=20`);
|
|
57
|
+
if (!res.ok) throw new Error(`Search failed (${res.status})`);
|
|
58
|
+
const data: SearchResponse = await res.json();
|
|
59
|
+
setResults(data.results ?? []);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
setError(err instanceof Error ? err.message : "Search failed");
|
|
62
|
+
setResults([]);
|
|
63
|
+
} finally {
|
|
64
|
+
setLoading(false);
|
|
65
|
+
}
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const handleQueryChange = useCallback(
|
|
69
|
+
(value: string) => {
|
|
70
|
+
setQuery(value);
|
|
71
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
72
|
+
debounceRef.current = setTimeout(() => performSearch(value), 300);
|
|
73
|
+
},
|
|
74
|
+
[performSearch],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (open) {
|
|
79
|
+
setTimeout(() => inputRef.current?.focus(), 100);
|
|
80
|
+
} else {
|
|
81
|
+
setQuery("");
|
|
82
|
+
setResults([]);
|
|
83
|
+
setError(null);
|
|
84
|
+
}
|
|
85
|
+
}, [open]);
|
|
86
|
+
|
|
87
|
+
const handleResultClick = (result: SearchResult) => {
|
|
88
|
+
const path = result.routeId.startsWith("/")
|
|
89
|
+
? result.routeId
|
|
90
|
+
: `/${result.modelId}/${result.routeId}`;
|
|
91
|
+
window.location.href = path;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
96
|
+
<DialogTrigger asChild>
|
|
97
|
+
<Button variant="ghost" size="icon" aria-label="Search">
|
|
98
|
+
<Search className="h-5 w-5" />
|
|
99
|
+
</Button>
|
|
100
|
+
</DialogTrigger>
|
|
101
|
+
<DialogContent className="sm:max-w-[600px]">
|
|
102
|
+
<DialogHeader>
|
|
103
|
+
<DialogTitle>Search</DialogTitle>
|
|
104
|
+
</DialogHeader>
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<div className="relative flex-1">
|
|
107
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
108
|
+
<Input
|
|
109
|
+
ref={inputRef}
|
|
110
|
+
value={query}
|
|
111
|
+
onChange={(e) => handleQueryChange(e.target.value)}
|
|
112
|
+
placeholder="Search content..."
|
|
113
|
+
className="pl-9 pr-9"
|
|
114
|
+
/>
|
|
115
|
+
{query && (
|
|
116
|
+
<button
|
|
117
|
+
onClick={() => handleQueryChange("")}
|
|
118
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
119
|
+
aria-label="Clear"
|
|
120
|
+
>
|
|
121
|
+
<X className="h-4 w-4" />
|
|
122
|
+
</button>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="mt-4 max-h-[400px] overflow-y-auto">
|
|
127
|
+
{loading && (
|
|
128
|
+
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
129
|
+
Searching...
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
{error && (
|
|
133
|
+
<p className="py-8 text-center text-sm text-destructive">{error}</p>
|
|
134
|
+
)}
|
|
135
|
+
{!loading && !error && query && results.length === 0 && (
|
|
136
|
+
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
137
|
+
No results found for “{query}”
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
{!loading && !error && !query && (
|
|
141
|
+
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
142
|
+
Type to search across all content.
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
{!loading && !error && results.length > 0 && (
|
|
146
|
+
<ul className="space-y-1">
|
|
147
|
+
{results.map((result) => (
|
|
148
|
+
<li key={`${result.modelId}-${result.routeId}`}>
|
|
149
|
+
<button
|
|
150
|
+
onClick={() => handleResultClick(result)}
|
|
151
|
+
className="flex w-full items-start gap-3 rounded-md p-3 text-left hover:bg-accent transition-colors"
|
|
152
|
+
>
|
|
153
|
+
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
154
|
+
<div className="min-w-0 flex-1">
|
|
155
|
+
<p className="truncate font-medium">{result.title}</p>
|
|
156
|
+
{result.summary && (
|
|
157
|
+
<p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">
|
|
158
|
+
{result.summary}
|
|
159
|
+
</p>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
</button>
|
|
163
|
+
</li>
|
|
164
|
+
))}
|
|
165
|
+
</ul>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
</DialogContent>
|
|
169
|
+
</Dialog>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Default `LocaleSwitcher` for the four built-in starter models.
|
|
2
|
+
// Renders one link per supported locale, following the locale-switcher
|
|
3
|
+
// rule from the design:
|
|
4
|
+
//
|
|
5
|
+
// - link to the same model's translated detail when one exists
|
|
6
|
+
// - fall back to the localized list when a detail is missing
|
|
7
|
+
//
|
|
8
|
+
// The component is intentionally a server component: the link list
|
|
9
|
+
// is computed from the request URL and the supported locales — no
|
|
10
|
+
// client state needed.
|
|
11
|
+
|
|
12
|
+
import Link from "next/link";
|
|
13
|
+
import { i18n, isAppLocale } from "@/lib/i18n";
|
|
14
|
+
import {
|
|
15
|
+
buildLocaleSwitcherLinks,
|
|
16
|
+
type LocaleSwitcherTranslation,
|
|
17
|
+
} from "@notionx/core";
|
|
18
|
+
import { blogContract } from "@/lib/locale-contract";
|
|
19
|
+
|
|
20
|
+
type Props = {
|
|
21
|
+
currentLocale: string;
|
|
22
|
+
currentSlug?: string;
|
|
23
|
+
translations?: readonly LocaleSwitcherTranslation[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function LocaleSwitcher({
|
|
27
|
+
currentLocale,
|
|
28
|
+
currentSlug,
|
|
29
|
+
translations = [],
|
|
30
|
+
}: Props) {
|
|
31
|
+
if (i18n.supportedLocales.length < 2) return null;
|
|
32
|
+
if (!isAppLocale(currentLocale)) return null;
|
|
33
|
+
|
|
34
|
+
const links = currentSlug
|
|
35
|
+
? buildLocaleSwitcherLinks({
|
|
36
|
+
contract: blogContract,
|
|
37
|
+
currentLocale,
|
|
38
|
+
defaultLocale: i18n.defaultLocale,
|
|
39
|
+
currentSlug,
|
|
40
|
+
supportedLocales: i18n.supportedLocales,
|
|
41
|
+
translations,
|
|
42
|
+
})
|
|
43
|
+
: i18n.supportedLocales.map((locale) => ({
|
|
44
|
+
locale,
|
|
45
|
+
href:
|
|
46
|
+
locale === i18n.defaultLocale
|
|
47
|
+
? blogContract.listPath
|
|
48
|
+
: `/${locale}${blogContract.listPath}`,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<nav aria-label="Language" className="flex items-center gap-2 text-sm">
|
|
53
|
+
{links.map((link) => (
|
|
54
|
+
<Link
|
|
55
|
+
key={link.locale}
|
|
56
|
+
href={link.href}
|
|
57
|
+
aria-current={link.locale === currentLocale ? "true" : undefined}
|
|
58
|
+
className="rounded px-2 py-1 hover:bg-muted"
|
|
59
|
+
>
|
|
60
|
+
{link.locale}
|
|
61
|
+
</Link>
|
|
62
|
+
))}
|
|
63
|
+
</nav>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { i18n } from "@/lib/i18n";
|
|
3
|
+
import { getSiteFooterGroups } from "@/lib/pages/source";
|
|
4
|
+
import { getSiteSettings } from "@/lib/site/settings";
|
|
5
|
+
|
|
6
|
+
type FooterColumn = {
|
|
7
|
+
label: string;
|
|
8
|
+
items: Array<{ label: string; href: string }>;
|
|
9
|
+
};
|
|
10
|
+
type SocialLink = { label: string; href: string };
|
|
11
|
+
|
|
12
|
+
export async function SiteFooter() {
|
|
13
|
+
const [pageGroups, settings] = await Promise.all([
|
|
14
|
+
getSiteFooterGroups() as Promise<FooterColumn[]>,
|
|
15
|
+
getSiteSettings(),
|
|
16
|
+
]);
|
|
17
|
+
const groups =
|
|
18
|
+
pageGroups.length > 0
|
|
19
|
+
? pageGroups
|
|
20
|
+
: (settings.footer.columns as unknown as FooterColumn[]);
|
|
21
|
+
const social = settings.footer.social as SocialLink[];
|
|
22
|
+
const tagline = settings.footer.tagline || settings.description;
|
|
23
|
+
const copyright = settings.footer.copyright;
|
|
24
|
+
|
|
25
|
+
// No columns → single-line copyright layout.
|
|
26
|
+
if (groups.length === 0) {
|
|
27
|
+
return (
|
|
28
|
+
<footer className="border-t bg-muted/30">
|
|
29
|
+
<div className="container mx-auto flex flex-col items-center justify-between gap-2 px-4 py-6 text-sm text-muted-foreground md:flex-row">
|
|
30
|
+
<span>{copyright}</span>
|
|
31
|
+
{i18n.supportedLocales.length > 1 ? (
|
|
32
|
+
<span>· {i18n.defaultLocale}</span>
|
|
33
|
+
) : null}
|
|
34
|
+
{social.length ? (
|
|
35
|
+
<div className="flex gap-4">
|
|
36
|
+
{social.map((item) => (
|
|
37
|
+
<Link
|
|
38
|
+
key={item.href}
|
|
39
|
+
href={item.href}
|
|
40
|
+
className="hover:text-foreground"
|
|
41
|
+
>
|
|
42
|
+
{item.label}
|
|
43
|
+
</Link>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
) : null}
|
|
47
|
+
</div>
|
|
48
|
+
</footer>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// With columns → multi-column grid + tagline + social row.
|
|
53
|
+
return (
|
|
54
|
+
<footer className="border-t bg-muted/30">
|
|
55
|
+
<div className="container mx-auto grid gap-8 px-4 py-10 md:grid-cols-[1.5fr_2fr]">
|
|
56
|
+
<div>
|
|
57
|
+
<Link href="/" className="font-semibold tracking-tight">
|
|
58
|
+
{settings.name}
|
|
59
|
+
</Link>
|
|
60
|
+
{tagline ? (
|
|
61
|
+
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
|
62
|
+
{tagline}
|
|
63
|
+
</p>
|
|
64
|
+
) : null}
|
|
65
|
+
</div>
|
|
66
|
+
<div className="grid gap-6 sm:grid-cols-2">
|
|
67
|
+
{groups.map((group) => (
|
|
68
|
+
<div key={group.label}>
|
|
69
|
+
<h2 className="text-sm font-medium">{group.label}</h2>
|
|
70
|
+
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
|
71
|
+
{group.items.map((item) => (
|
|
72
|
+
<li key={item.href}>
|
|
73
|
+
<Link href={item.href} className="hover:text-foreground">
|
|
74
|
+
{item.label}
|
|
75
|
+
</Link>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
{social.length ? (
|
|
84
|
+
<div className="border-t">
|
|
85
|
+
<div className="container mx-auto flex flex-col items-center justify-between gap-2 px-4 py-4 text-sm text-muted-foreground md:flex-row">
|
|
86
|
+
<span>
|
|
87
|
+
{copyright}
|
|
88
|
+
{i18n.supportedLocales.length > 1 ? ` · ${i18n.defaultLocale}` : ""}
|
|
89
|
+
</span>
|
|
90
|
+
<div className="flex gap-4">
|
|
91
|
+
{social.map((item) => (
|
|
92
|
+
<Link
|
|
93
|
+
key={item.href}
|
|
94
|
+
href={item.href}
|
|
95
|
+
className="hover:text-foreground"
|
|
96
|
+
>
|
|
97
|
+
{item.label}
|
|
98
|
+
</Link>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
</footer>
|
|
105
|
+
);
|
|
106
|
+
}
|