@estation/create-cms-site 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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/list-sections.d.ts +19 -0
- package/dist/tools/list-sections.d.ts.map +1 -0
- package/dist/tools/list-sections.js +152 -0
- package/dist/tools/list-sections.js.map +1 -0
- package/dist/tools/scaffold-project.d.ts +11 -0
- package/dist/tools/scaffold-project.d.ts.map +1 -0
- package/dist/tools/scaffold-project.js +90 -0
- package/dist/tools/scaffold-project.js.map +1 -0
- package/dist/tools/validate-config.d.ts +9 -0
- package/dist/tools/validate-config.d.ts.map +1 -0
- package/dist/tools/validate-config.js +72 -0
- package/dist/tools/validate-config.js.map +1 -0
- package/dist/utils/helpers.d.ts +2 -0
- package/dist/utils/helpers.d.ts.map +1 -0
- package/dist/utils/helpers.js +5 -0
- package/dist/utils/helpers.js.map +1 -0
- package/package.json +36 -0
- package/template/.env.example +9 -0
- package/template/LIVE-PREVIEW.md +267 -0
- package/template/README.md +210 -0
- package/template/next.config.ts +14 -0
- package/template/package.json +24 -0
- package/template/postcss.config.mjs +8 -0
- package/template/src/app/[slug]/page.tsx +38 -0
- package/template/src/app/api/revalidate/route.ts +34 -0
- package/template/src/app/blog/[slug]/page.tsx +60 -0
- package/template/src/app/blog/page.tsx +68 -0
- package/template/src/app/collections/[uuid]/page.tsx +28 -0
- package/template/src/app/events/[slug]/page.tsx +62 -0
- package/template/src/app/events/page.tsx +70 -0
- package/template/src/app/layout.tsx +27 -0
- package/template/src/app/news/[slug]/page.tsx +66 -0
- package/template/src/app/news/page.tsx +68 -0
- package/template/src/app/page.tsx +40 -0
- package/template/src/app/search/page.tsx +136 -0
- package/template/src/app/sitemap.ts +48 -0
- package/template/src/components/Footer.tsx +47 -0
- package/template/src/components/Navigation.tsx +65 -0
- package/template/src/components/SectionRenderer.tsx +43 -0
- package/template/src/components/cms-preview-listener.tsx +114 -0
- package/template/src/components/sections/CTASection.tsx +32 -0
- package/template/src/components/sections/ContactSection.tsx +74 -0
- package/template/src/components/sections/FAQSection.tsx +48 -0
- package/template/src/components/sections/FeaturesSection.tsx +42 -0
- package/template/src/components/sections/GallerySection.tsx +44 -0
- package/template/src/components/sections/GenericSection.tsx +63 -0
- package/template/src/components/sections/HeroSection.tsx +27 -0
- package/template/src/components/sections/SliderSection.tsx +66 -0
- package/template/src/components/sections/TestimonialsSection.tsx +52 -0
- package/template/src/components/sections/TextSection.tsx +31 -0
- package/template/src/lib/cms-api.ts +103 -0
- package/template/src/lib/content-helpers.ts +18 -0
- package/template/src/lib/types.ts +109 -0
- package/template/src/styles/globals.css +1 -0
- package/template/tsconfig.json +23 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getBlocksByTags } from "@/lib/cms-api";
|
|
2
|
+
import { findBlockBySlug, formatDate } from "@/lib/content-helpers";
|
|
3
|
+
import { str } from "@/lib/types";
|
|
4
|
+
import { notFound } from "next/navigation";
|
|
5
|
+
import type { Metadata } from "next";
|
|
6
|
+
|
|
7
|
+
interface PageProps {
|
|
8
|
+
params: Promise<{ slug: string }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
12
|
+
const { slug } = await params;
|
|
13
|
+
const blocks = await getBlocksByTags(["blogs"]);
|
|
14
|
+
const block = findBlockBySlug(blocks, slug);
|
|
15
|
+
if (!block) return { title: "Post Not Found" };
|
|
16
|
+
return {
|
|
17
|
+
title: str(block.content.title, block.name),
|
|
18
|
+
description: str(block.content.excerpt),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default async function BlogDetailPage({ params }: PageProps) {
|
|
23
|
+
const { slug } = await params;
|
|
24
|
+
const blocks = await getBlocksByTags(["blogs"]);
|
|
25
|
+
const block = findBlockBySlug(blocks, slug);
|
|
26
|
+
|
|
27
|
+
if (!block) notFound();
|
|
28
|
+
|
|
29
|
+
const title = str(block.content.title, block.name);
|
|
30
|
+
const author = str(block.content.author);
|
|
31
|
+
const date = str(block.content.publishDate) || block.created_at;
|
|
32
|
+
const featuredImage = str(block.content.featuredImage);
|
|
33
|
+
const content = str(block.content.content);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<article className="py-16 px-6">
|
|
37
|
+
<div className="max-w-3xl mx-auto">
|
|
38
|
+
<a href="/blog" className="text-sm text-gray-500 hover:underline">← Back to Blog</a>
|
|
39
|
+
<h1 className="text-4xl font-bold mt-4 mb-4">{title}</h1>
|
|
40
|
+
<div className="flex gap-4 text-sm text-gray-500 mb-8">
|
|
41
|
+
{author && <span>By {author}</span>}
|
|
42
|
+
<span>{formatDate(date)}</span>
|
|
43
|
+
</div>
|
|
44
|
+
{featuredImage && (
|
|
45
|
+
<img
|
|
46
|
+
src={featuredImage}
|
|
47
|
+
alt={title}
|
|
48
|
+
className="w-full rounded-lg mb-8"
|
|
49
|
+
/>
|
|
50
|
+
)}
|
|
51
|
+
{content && (
|
|
52
|
+
<div
|
|
53
|
+
className="prose prose-gray max-w-none"
|
|
54
|
+
dangerouslySetInnerHTML={{ __html: content }}
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
</article>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getBlocksByTags } from "@/lib/cms-api";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
import { formatDate } from "@/lib/content-helpers";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: "Blog",
|
|
8
|
+
description: "Latest blog posts",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default async function BlogListingPage() {
|
|
12
|
+
let blocks: Awaited<ReturnType<typeof getBlocksByTags>> = [];
|
|
13
|
+
try {
|
|
14
|
+
blocks = await getBlocksByTags(["blogs"]);
|
|
15
|
+
} catch {
|
|
16
|
+
// CMS unavailable
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sorted = [...blocks].sort((a, b) => {
|
|
20
|
+
const da = str(a.content.publishDate) || a.created_at;
|
|
21
|
+
const db = str(b.content.publishDate) || b.created_at;
|
|
22
|
+
return new Date(db).getTime() - new Date(da).getTime();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="py-16 px-6">
|
|
27
|
+
<div className="max-w-4xl mx-auto">
|
|
28
|
+
<h1 className="text-4xl font-bold mb-10">Blog</h1>
|
|
29
|
+
{sorted.length === 0 ? (
|
|
30
|
+
<p className="text-gray-500">No blog posts yet.</p>
|
|
31
|
+
) : (
|
|
32
|
+
<div className="space-y-8">
|
|
33
|
+
{sorted.map((block) => {
|
|
34
|
+
const slug = str(block.content.slug);
|
|
35
|
+
const title = str(block.content.title, block.name);
|
|
36
|
+
const excerpt = str(block.content.excerpt);
|
|
37
|
+
const featuredImage = str(block.content.featuredImage);
|
|
38
|
+
const date = str(block.content.publishDate) || block.created_at;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<article key={block.uuid} className="border-b pb-8">
|
|
42
|
+
{featuredImage && (
|
|
43
|
+
<img
|
|
44
|
+
src={featuredImage}
|
|
45
|
+
alt={title}
|
|
46
|
+
className="w-full h-48 object-cover rounded-lg mb-4"
|
|
47
|
+
/>
|
|
48
|
+
)}
|
|
49
|
+
<h2 className="text-2xl font-semibold">
|
|
50
|
+
{slug ? (
|
|
51
|
+
<a href={`/blog/${slug}`} className="hover:underline">
|
|
52
|
+
{title}
|
|
53
|
+
</a>
|
|
54
|
+
) : (
|
|
55
|
+
title
|
|
56
|
+
)}
|
|
57
|
+
</h2>
|
|
58
|
+
<p className="text-sm text-gray-500 mt-1">{formatDate(date)}</p>
|
|
59
|
+
{excerpt && <p className="text-gray-600 mt-2">{excerpt}</p>}
|
|
60
|
+
</article>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { executeCollection } from "@/lib/cms-api";
|
|
2
|
+
import { SectionRenderer } from "@/components/SectionRenderer";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
|
|
5
|
+
interface PageProps {
|
|
6
|
+
params: Promise<{ uuid: string }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const metadata: Metadata = {
|
|
10
|
+
title: "Collection",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default async function CollectionPage({ params }: PageProps) {
|
|
14
|
+
const { uuid } = await params;
|
|
15
|
+
const blocks = await executeCollection(uuid);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="py-16 px-6">
|
|
19
|
+
<div className="max-w-6xl mx-auto">
|
|
20
|
+
{blocks.length === 0 ? (
|
|
21
|
+
<p className="text-gray-500 text-center">No items in this collection.</p>
|
|
22
|
+
) : (
|
|
23
|
+
<SectionRenderer blocks={blocks} />
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getBlocksByTags } from "@/lib/cms-api";
|
|
2
|
+
import { findBlockBySlug, formatDate } from "@/lib/content-helpers";
|
|
3
|
+
import { str } from "@/lib/types";
|
|
4
|
+
import { notFound } from "next/navigation";
|
|
5
|
+
import type { Metadata } from "next";
|
|
6
|
+
|
|
7
|
+
interface PageProps {
|
|
8
|
+
params: Promise<{ slug: string }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
12
|
+
const { slug } = await params;
|
|
13
|
+
const blocks = await getBlocksByTags(["events"]);
|
|
14
|
+
const block = findBlockBySlug(blocks, slug);
|
|
15
|
+
if (!block) return { title: "Event Not Found" };
|
|
16
|
+
return {
|
|
17
|
+
title: str(block.content.title, block.name),
|
|
18
|
+
description: str(block.content.description),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default async function EventDetailPage({ params }: PageProps) {
|
|
23
|
+
const { slug } = await params;
|
|
24
|
+
const blocks = await getBlocksByTags(["events"]);
|
|
25
|
+
const block = findBlockBySlug(blocks, slug);
|
|
26
|
+
|
|
27
|
+
if (!block) notFound();
|
|
28
|
+
|
|
29
|
+
const title = str(block.content.title, block.name);
|
|
30
|
+
const description = str(block.content.description);
|
|
31
|
+
const location = str(block.content.location);
|
|
32
|
+
const startDate = str(block.content.startDate);
|
|
33
|
+
const endDate = str(block.content.endDate);
|
|
34
|
+
const organizer = str(block.content.organizer);
|
|
35
|
+
const content = str(block.content.content);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<article className="py-16 px-6">
|
|
39
|
+
<div className="max-w-3xl mx-auto">
|
|
40
|
+
<a href="/events" className="text-sm text-gray-500 hover:underline">← Back to Events</a>
|
|
41
|
+
<h1 className="text-4xl font-bold mt-4 mb-4">{title}</h1>
|
|
42
|
+
<div className="flex flex-wrap gap-4 text-sm text-gray-500 mb-8">
|
|
43
|
+
{startDate && (
|
|
44
|
+
<span>
|
|
45
|
+
{formatDate(startDate)}
|
|
46
|
+
{endDate && ` – ${formatDate(endDate)}`}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
{location && <span>{location}</span>}
|
|
50
|
+
{organizer && <span>Organized by {organizer}</span>}
|
|
51
|
+
</div>
|
|
52
|
+
{description && <p className="text-lg text-gray-700 mb-6">{description}</p>}
|
|
53
|
+
{content && (
|
|
54
|
+
<div
|
|
55
|
+
className="prose prose-gray max-w-none"
|
|
56
|
+
dangerouslySetInnerHTML={{ __html: content }}
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
</article>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getBlocksByTags } from "@/lib/cms-api";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
import { formatDate } from "@/lib/content-helpers";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: "Events",
|
|
8
|
+
description: "Upcoming events",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default async function EventsListingPage() {
|
|
12
|
+
let blocks: Awaited<ReturnType<typeof getBlocksByTags>> = [];
|
|
13
|
+
try {
|
|
14
|
+
blocks = await getBlocksByTags(["events"]);
|
|
15
|
+
} catch {
|
|
16
|
+
// CMS unavailable
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sorted = [...blocks].sort((a, b) => {
|
|
20
|
+
const da = str(a.content.startDate) || a.created_at;
|
|
21
|
+
const db = str(b.content.startDate) || b.created_at;
|
|
22
|
+
return new Date(da).getTime() - new Date(db).getTime();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="py-16 px-6">
|
|
27
|
+
<div className="max-w-4xl mx-auto">
|
|
28
|
+
<h1 className="text-4xl font-bold mb-10">Events</h1>
|
|
29
|
+
{sorted.length === 0 ? (
|
|
30
|
+
<p className="text-gray-500">No events scheduled.</p>
|
|
31
|
+
) : (
|
|
32
|
+
<div className="space-y-8">
|
|
33
|
+
{sorted.map((block) => {
|
|
34
|
+
const slug = str(block.content.slug);
|
|
35
|
+
const title = str(block.content.title, block.name);
|
|
36
|
+
const description = str(block.content.description);
|
|
37
|
+
const location = str(block.content.location);
|
|
38
|
+
const startDate = str(block.content.startDate);
|
|
39
|
+
const endDate = str(block.content.endDate);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<article key={block.uuid} className="border-b pb-8">
|
|
43
|
+
<h2 className="text-2xl font-semibold">
|
|
44
|
+
{slug ? (
|
|
45
|
+
<a href={`/events/${slug}`} className="hover:underline">
|
|
46
|
+
{title}
|
|
47
|
+
</a>
|
|
48
|
+
) : (
|
|
49
|
+
title
|
|
50
|
+
)}
|
|
51
|
+
</h2>
|
|
52
|
+
<div className="flex flex-wrap gap-4 text-sm text-gray-500 mt-2">
|
|
53
|
+
{startDate && (
|
|
54
|
+
<span>
|
|
55
|
+
{formatDate(startDate)}
|
|
56
|
+
{endDate && ` – ${formatDate(endDate)}`}
|
|
57
|
+
</span>
|
|
58
|
+
)}
|
|
59
|
+
{location && <span>{location}</span>}
|
|
60
|
+
</div>
|
|
61
|
+
{description && <p className="text-gray-600 mt-2">{description}</p>}
|
|
62
|
+
</article>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { CMSPreviewListener } from "@/components/cms-preview-listener";
|
|
3
|
+
import { Navigation } from "@/components/Navigation";
|
|
4
|
+
import { Footer } from "@/components/Footer";
|
|
5
|
+
import "@/styles/globals.css";
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: "Website",
|
|
9
|
+
description: "Powered by CMS",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function RootLayout({
|
|
13
|
+
children,
|
|
14
|
+
}: {
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<body className="antialiased">
|
|
20
|
+
<Navigation />
|
|
21
|
+
<main>{children}</main>
|
|
22
|
+
<Footer />
|
|
23
|
+
<CMSPreviewListener />
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getBlocksByTags } from "@/lib/cms-api";
|
|
2
|
+
import { findBlockBySlug, formatDate } from "@/lib/content-helpers";
|
|
3
|
+
import { str } from "@/lib/types";
|
|
4
|
+
import { notFound } from "next/navigation";
|
|
5
|
+
import type { Metadata } from "next";
|
|
6
|
+
|
|
7
|
+
interface PageProps {
|
|
8
|
+
params: Promise<{ slug: string }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
12
|
+
const { slug } = await params;
|
|
13
|
+
const blocks = await getBlocksByTags(["news"]);
|
|
14
|
+
const block = findBlockBySlug(blocks, slug);
|
|
15
|
+
if (!block) return { title: "Article Not Found" };
|
|
16
|
+
return {
|
|
17
|
+
title: str(block.content.title, block.name),
|
|
18
|
+
description: str(block.content.excerpt),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default async function NewsDetailPage({ params }: PageProps) {
|
|
23
|
+
const { slug } = await params;
|
|
24
|
+
const blocks = await getBlocksByTags(["news"]);
|
|
25
|
+
const block = findBlockBySlug(blocks, slug);
|
|
26
|
+
|
|
27
|
+
if (!block) notFound();
|
|
28
|
+
|
|
29
|
+
const title = str(block.content.title, block.name);
|
|
30
|
+
const author = str(block.content.author);
|
|
31
|
+
const category = str(block.content.category);
|
|
32
|
+
const date = str(block.content.publishDate) || block.created_at;
|
|
33
|
+
const featuredImage = str(block.content.featuredImage);
|
|
34
|
+
const content = str(block.content.content);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<article className="py-16 px-6">
|
|
38
|
+
<div className="max-w-3xl mx-auto">
|
|
39
|
+
<a href="/news" className="text-sm text-gray-500 hover:underline">← Back to News</a>
|
|
40
|
+
{category && (
|
|
41
|
+
<span className="ml-4 text-xs font-medium uppercase tracking-wide text-blue-600 bg-blue-50 px-2 py-1 rounded">
|
|
42
|
+
{category}
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
<h1 className="text-4xl font-bold mt-4 mb-4">{title}</h1>
|
|
46
|
+
<div className="flex gap-4 text-sm text-gray-500 mb-8">
|
|
47
|
+
{author && <span>By {author}</span>}
|
|
48
|
+
<span>{formatDate(date)}</span>
|
|
49
|
+
</div>
|
|
50
|
+
{featuredImage && (
|
|
51
|
+
<img
|
|
52
|
+
src={featuredImage}
|
|
53
|
+
alt={title}
|
|
54
|
+
className="w-full rounded-lg mb-8"
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
{content && (
|
|
58
|
+
<div
|
|
59
|
+
className="prose prose-gray max-w-none"
|
|
60
|
+
dangerouslySetInnerHTML={{ __html: content }}
|
|
61
|
+
/>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
</article>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getBlocksByTags } from "@/lib/cms-api";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
import { formatDate } from "@/lib/content-helpers";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: "News",
|
|
8
|
+
description: "Latest news",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default async function NewsListingPage() {
|
|
12
|
+
let blocks: Awaited<ReturnType<typeof getBlocksByTags>> = [];
|
|
13
|
+
try {
|
|
14
|
+
blocks = await getBlocksByTags(["news"]);
|
|
15
|
+
} catch {
|
|
16
|
+
// CMS unavailable
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sorted = [...blocks].sort((a, b) => {
|
|
20
|
+
const da = str(a.content.publishDate) || a.created_at;
|
|
21
|
+
const db = str(b.content.publishDate) || b.created_at;
|
|
22
|
+
return new Date(db).getTime() - new Date(da).getTime();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="py-16 px-6">
|
|
27
|
+
<div className="max-w-4xl mx-auto">
|
|
28
|
+
<h1 className="text-4xl font-bold mb-10">News</h1>
|
|
29
|
+
{sorted.length === 0 ? (
|
|
30
|
+
<p className="text-gray-500">No news articles yet.</p>
|
|
31
|
+
) : (
|
|
32
|
+
<div className="space-y-8">
|
|
33
|
+
{sorted.map((block) => {
|
|
34
|
+
const slug = str(block.content.slug);
|
|
35
|
+
const title = str(block.content.title, block.name);
|
|
36
|
+
const excerpt = str(block.content.excerpt);
|
|
37
|
+
const category = str(block.content.category);
|
|
38
|
+
const date = str(block.content.publishDate) || block.created_at;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<article key={block.uuid} className="border-b pb-8">
|
|
42
|
+
<div className="flex items-center gap-3 mb-2">
|
|
43
|
+
{category && (
|
|
44
|
+
<span className="text-xs font-medium uppercase tracking-wide text-blue-600 bg-blue-50 px-2 py-1 rounded">
|
|
45
|
+
{category}
|
|
46
|
+
</span>
|
|
47
|
+
)}
|
|
48
|
+
<span className="text-sm text-gray-500">{formatDate(date)}</span>
|
|
49
|
+
</div>
|
|
50
|
+
<h2 className="text-2xl font-semibold">
|
|
51
|
+
{slug ? (
|
|
52
|
+
<a href={`/news/${slug}`} className="hover:underline">
|
|
53
|
+
{title}
|
|
54
|
+
</a>
|
|
55
|
+
) : (
|
|
56
|
+
title
|
|
57
|
+
)}
|
|
58
|
+
</h2>
|
|
59
|
+
{excerpt && <p className="text-gray-600 mt-2">{excerpt}</p>}
|
|
60
|
+
</article>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getPageBySlug } from "@/lib/cms-api";
|
|
2
|
+
import { SectionRenderer } from "@/components/SectionRenderer";
|
|
3
|
+
import type { ContentBlock } from "@/lib/types";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
|
|
6
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
7
|
+
try {
|
|
8
|
+
const data = await getPageBySlug("index");
|
|
9
|
+
return {
|
|
10
|
+
title: data.page.title || "Home",
|
|
11
|
+
description: data.page.description || undefined,
|
|
12
|
+
};
|
|
13
|
+
} catch {
|
|
14
|
+
return { title: "Home" };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function Home() {
|
|
19
|
+
try {
|
|
20
|
+
const data = await getPageBySlug("index");
|
|
21
|
+
const orderedBlocks = getOrderedBlocks(data.page.blocks, data.blocks);
|
|
22
|
+
return <SectionRenderer blocks={orderedBlocks} />;
|
|
23
|
+
} catch {
|
|
24
|
+
return (
|
|
25
|
+
<div className="py-20 px-6 text-center">
|
|
26
|
+
<h1 className="text-4xl font-bold">Welcome</h1>
|
|
27
|
+
<p className="mt-4 text-gray-500">Content is not available yet. Configure your CMS to get started.</p>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getOrderedBlocks(
|
|
34
|
+
blockUuids: string[],
|
|
35
|
+
blocksMap: Record<string, ContentBlock>
|
|
36
|
+
): ContentBlock[] {
|
|
37
|
+
return blockUuids
|
|
38
|
+
.map((uuid) => blocksMap[uuid])
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { searchContent } from "@/lib/cms-api";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Search",
|
|
7
|
+
description: "Search content",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
interface SearchPageProps {
|
|
11
|
+
searchParams: Promise<{ q?: string; type?: string; page?: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default async function SearchPage({ searchParams }: SearchPageProps) {
|
|
15
|
+
const { q, type, page: pageStr } = await searchParams;
|
|
16
|
+
const query = q || "";
|
|
17
|
+
const currentPage = Math.max(1, parseInt(pageStr || "1", 10));
|
|
18
|
+
|
|
19
|
+
const results = query
|
|
20
|
+
? await searchContent(query, type, currentPage, 20)
|
|
21
|
+
: null;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="py-16 px-6">
|
|
25
|
+
<div className="max-w-4xl mx-auto">
|
|
26
|
+
<h1 className="text-4xl font-bold mb-8">Search</h1>
|
|
27
|
+
|
|
28
|
+
<form method="get" action="/search" className="flex gap-2 mb-10">
|
|
29
|
+
<input
|
|
30
|
+
type="text"
|
|
31
|
+
name="q"
|
|
32
|
+
defaultValue={query}
|
|
33
|
+
placeholder="Search..."
|
|
34
|
+
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-300"
|
|
35
|
+
/>
|
|
36
|
+
<button
|
|
37
|
+
type="submit"
|
|
38
|
+
className="px-6 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
|
39
|
+
>
|
|
40
|
+
Search
|
|
41
|
+
</button>
|
|
42
|
+
</form>
|
|
43
|
+
|
|
44
|
+
{results && (
|
|
45
|
+
<>
|
|
46
|
+
<p className="text-sm text-gray-500 mb-6">
|
|
47
|
+
{results.total} result{results.total !== 1 ? "s" : ""} for “{query}”
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
{results.data.length === 0 ? (
|
|
51
|
+
<p className="text-gray-500">No results found.</p>
|
|
52
|
+
) : (
|
|
53
|
+
<div className="space-y-6">
|
|
54
|
+
{results.data.map((item, i) => {
|
|
55
|
+
if (item.type === "page" && item.page) {
|
|
56
|
+
return (
|
|
57
|
+
<div key={`page-${item.page.uuid}`} className="border-b pb-4">
|
|
58
|
+
<span className="text-xs font-medium uppercase text-green-600 bg-green-50 px-2 py-0.5 rounded">
|
|
59
|
+
Page
|
|
60
|
+
</span>
|
|
61
|
+
<h2 className="text-xl font-semibold mt-1">
|
|
62
|
+
<a
|
|
63
|
+
href={item.page.slug === "index" ? "/" : `/${item.page.slug}`}
|
|
64
|
+
className="hover:underline"
|
|
65
|
+
>
|
|
66
|
+
{item.page.title}
|
|
67
|
+
</a>
|
|
68
|
+
</h2>
|
|
69
|
+
{item.page.description && (
|
|
70
|
+
<p className="text-gray-600 mt-1">{item.page.description}</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (item.type === "block" && item.block) {
|
|
77
|
+
const title = str(item.block.content.title, item.block.name);
|
|
78
|
+
const slug = str(item.block.content.slug);
|
|
79
|
+
const tag = item.block.tags?.[0] || "";
|
|
80
|
+
let href = "#";
|
|
81
|
+
if (slug) {
|
|
82
|
+
if (tag === "blogs") href = `/blog/${slug}`;
|
|
83
|
+
else if (tag === "news") href = `/news/${slug}`;
|
|
84
|
+
else if (tag === "events") href = `/events/${slug}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div key={`block-${item.block.uuid}-${i}`} className="border-b pb-4">
|
|
89
|
+
<span className="text-xs font-medium uppercase text-purple-600 bg-purple-50 px-2 py-0.5 rounded">
|
|
90
|
+
{tag || "Block"}
|
|
91
|
+
</span>
|
|
92
|
+
<h2 className="text-xl font-semibold mt-1">
|
|
93
|
+
{href !== "#" ? (
|
|
94
|
+
<a href={href} className="hover:underline">{title}</a>
|
|
95
|
+
) : (
|
|
96
|
+
title
|
|
97
|
+
)}
|
|
98
|
+
</h2>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{results.total_pages > 1 && (
|
|
109
|
+
<div className="flex justify-center gap-2 mt-10">
|
|
110
|
+
{currentPage > 1 && (
|
|
111
|
+
<a
|
|
112
|
+
href={`/search?q=${encodeURIComponent(query)}&page=${currentPage - 1}${type ? `&type=${type}` : ""}`}
|
|
113
|
+
className="px-4 py-2 border rounded-lg hover:bg-gray-100"
|
|
114
|
+
>
|
|
115
|
+
Previous
|
|
116
|
+
</a>
|
|
117
|
+
)}
|
|
118
|
+
<span className="flex items-center px-4 text-sm text-gray-500">
|
|
119
|
+
Page {currentPage} of {results.total_pages}
|
|
120
|
+
</span>
|
|
121
|
+
{currentPage < results.total_pages && (
|
|
122
|
+
<a
|
|
123
|
+
href={`/search?q=${encodeURIComponent(query)}&page=${currentPage + 1}${type ? `&type=${type}` : ""}`}
|
|
124
|
+
className="px-4 py-2 border rounded-lg hover:bg-gray-100"
|
|
125
|
+
>
|
|
126
|
+
Next
|
|
127
|
+
</a>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { getAllPages, getBlocksByTags } from "@/lib/cms-api";
|
|
3
|
+
import { str } from "@/lib/types";
|
|
4
|
+
|
|
5
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
|
|
6
|
+
|
|
7
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
8
|
+
const entries: MetadataRoute.Sitemap = [];
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const pages = await getAllPages();
|
|
12
|
+
for (const page of pages) {
|
|
13
|
+
if (!page.is_published) continue;
|
|
14
|
+
const slug = page.slug === "index" || page.slug === "" ? "" : page.slug;
|
|
15
|
+
entries.push({
|
|
16
|
+
url: `${SITE_URL}/${slug}`,
|
|
17
|
+
lastModified: page.updated_at,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
entries.push({ url: SITE_URL, lastModified: new Date().toISOString() });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const contentTypes = [
|
|
25
|
+
{ tag: "blogs", prefix: "blog" },
|
|
26
|
+
{ tag: "news", prefix: "news" },
|
|
27
|
+
{ tag: "events", prefix: "events" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
for (const { tag, prefix } of contentTypes) {
|
|
31
|
+
try {
|
|
32
|
+
const blocks = await getBlocksByTags([tag]);
|
|
33
|
+
for (const block of blocks) {
|
|
34
|
+
const slug = str(block.content.slug);
|
|
35
|
+
if (slug) {
|
|
36
|
+
entries.push({
|
|
37
|
+
url: `${SITE_URL}/${prefix}/${slug}`,
|
|
38
|
+
lastModified: block.updated_at,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// tag not available
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return entries;
|
|
48
|
+
}
|