@estation/create-cms-site 2.6.0 → 2.7.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/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getContentBySlug } from "@/lib/cms-api";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
import { notFound } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
interface PageProps {
|
|
7
|
+
params: Promise<{ locale: string; typeName: string; slug: string }>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
11
|
+
const { typeName, slug, locale } = await params;
|
|
12
|
+
try {
|
|
13
|
+
const block = await getContentBySlug(typeName, slug);
|
|
14
|
+
return {
|
|
15
|
+
title: str(block.content?.title, locale, block.name),
|
|
16
|
+
description: str(block.content?.excerpt || block.content?.description, locale),
|
|
17
|
+
};
|
|
18
|
+
} catch {
|
|
19
|
+
return { title: typeName };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function ContentDetailPage({ params }: PageProps) {
|
|
24
|
+
const { locale, typeName, slug } = await params;
|
|
25
|
+
|
|
26
|
+
let block;
|
|
27
|
+
try {
|
|
28
|
+
block = await getContentBySlug(typeName, slug);
|
|
29
|
+
} catch {
|
|
30
|
+
notFound();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!block) notFound();
|
|
34
|
+
|
|
35
|
+
const c = block.content || {};
|
|
36
|
+
const title = str(c.title, locale, block.name);
|
|
37
|
+
const image = str(c.featuredImage || c.image);
|
|
38
|
+
const body = str(c.content || c.body || c.description, locale);
|
|
39
|
+
const author = str(c.author, locale);
|
|
40
|
+
const date = str(c.publishDate || c.startDate) || block.created_at;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="py-16 px-6">
|
|
44
|
+
<div className="max-w-3xl mx-auto">
|
|
45
|
+
<a href={`/${locale}/content/${typeName}`} className="text-sm text-gray-500 hover:underline mb-6 block">
|
|
46
|
+
← Back to {typeName}
|
|
47
|
+
</a>
|
|
48
|
+
|
|
49
|
+
{image && (
|
|
50
|
+
<img src={image} alt={title} className="w-full h-64 object-cover rounded-xl mb-8" />
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
<h1 data-cms-field="title" className="text-4xl font-bold">{title}</h1>
|
|
54
|
+
|
|
55
|
+
{(author || date) && (
|
|
56
|
+
<p className="text-sm text-gray-500 mt-3">
|
|
57
|
+
{author && <span data-cms-field="author">By {author}</span>}
|
|
58
|
+
{author && date && " · "}
|
|
59
|
+
{date && <time>{new Date(date).toLocaleDateString()}</time>}
|
|
60
|
+
</p>
|
|
61
|
+
)}
|
|
62
|
+
|
|
63
|
+
{body && (
|
|
64
|
+
<div
|
|
65
|
+
data-cms-field="content"
|
|
66
|
+
className="prose prose-gray max-w-none mt-8"
|
|
67
|
+
dangerouslySetInnerHTML={{ __html: body }}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{/* Render all other text fields */}
|
|
72
|
+
{Object.entries(c).map(([key, field]) => {
|
|
73
|
+
if (["title", "slug", "content", "body", "description", "featuredImage", "image", "author", "publishDate", "startDate", "status", "tags"].includes(key)) return null;
|
|
74
|
+
const val = str(field, locale);
|
|
75
|
+
if (!val) return null;
|
|
76
|
+
const label = key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
|
|
77
|
+
return (
|
|
78
|
+
<div key={key} className="mt-6">
|
|
79
|
+
<h3 className="text-sm font-medium text-gray-500 uppercase">{label}</h3>
|
|
80
|
+
<p data-cms-field={key} className="mt-1">{val}</p>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getContentByType } from "@/lib/cms-api";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
|
|
5
|
+
interface PageProps {
|
|
6
|
+
params: Promise<{ locale: string; typeName: string }>;
|
|
7
|
+
searchParams: Promise<{ page?: string }>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
11
|
+
const { typeName } = await params;
|
|
12
|
+
const label = typeName.charAt(0).toUpperCase() + typeName.slice(1);
|
|
13
|
+
return { title: label, description: `Browse ${label}` };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default async function ContentListPage({ params, searchParams }: PageProps) {
|
|
17
|
+
const { locale, typeName } = await params;
|
|
18
|
+
const { page: pageParam } = await searchParams;
|
|
19
|
+
const page = parseInt(pageParam || "1", 10);
|
|
20
|
+
|
|
21
|
+
let result;
|
|
22
|
+
try {
|
|
23
|
+
result = await getContentByType(typeName, page, 20);
|
|
24
|
+
} catch {
|
|
25
|
+
result = { data: [], total: 0, page: 1, size: 20, total_pages: 0 };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const blocks = result.data || [];
|
|
29
|
+
const label = typeName.charAt(0).toUpperCase() + typeName.slice(1);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="py-16 px-6">
|
|
33
|
+
<div className="max-w-4xl mx-auto">
|
|
34
|
+
<h1 className="text-4xl font-bold mb-10">{label}</h1>
|
|
35
|
+
{blocks.length === 0 ? (
|
|
36
|
+
<p className="text-gray-500">No {typeName} content yet.</p>
|
|
37
|
+
) : (
|
|
38
|
+
<div className="space-y-8">
|
|
39
|
+
{blocks.map((block) => {
|
|
40
|
+
const slug = str(block.content?.slug);
|
|
41
|
+
const title = str(block.content?.title, locale, block.name);
|
|
42
|
+
const excerpt = str(block.content?.excerpt || block.content?.description, locale);
|
|
43
|
+
const image = str(block.content?.featuredImage || block.content?.image);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<article key={block.uuid} className="border-b pb-8">
|
|
47
|
+
{image && (
|
|
48
|
+
<img src={image} alt={title} className="w-full h-48 object-cover rounded-lg mb-4" />
|
|
49
|
+
)}
|
|
50
|
+
<h2 className="text-2xl font-semibold">
|
|
51
|
+
{slug ? (
|
|
52
|
+
<a href={`/${locale}/content/${typeName}/${slug}`} className="hover:underline">{title}</a>
|
|
53
|
+
) : title}
|
|
54
|
+
</h2>
|
|
55
|
+
{excerpt && <p className="text-gray-600 mt-2">{excerpt}</p>}
|
|
56
|
+
</article>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{/* Pagination */}
|
|
63
|
+
{result.total_pages > 1 && (
|
|
64
|
+
<div className="flex justify-center gap-2 mt-10">
|
|
65
|
+
{page > 1 && (
|
|
66
|
+
<a href={`/${locale}/content/${typeName}?page=${page - 1}`} className="px-4 py-2 border rounded hover:bg-gray-50">Previous</a>
|
|
67
|
+
)}
|
|
68
|
+
<span className="px-4 py-2 text-gray-500">Page {page} of {result.total_pages}</span>
|
|
69
|
+
{page < result.total_pages && (
|
|
70
|
+
<a href={`/${locale}/content/${typeName}?page=${page + 1}`} className="px-4 py-2 border rounded hover:bg-gray-50">Next</a>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -93,6 +93,53 @@ export async function getCollections(): Promise<Collection[]> {
|
|
|
93
93
|
return cmsFetch<Collection[]>("/public/content/collections", ["collections"]);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ── v2 API functions ──────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const CMS_V2_URL = CMS_API_URL.replace("/api/v1", "/api/v2");
|
|
99
|
+
|
|
100
|
+
async function cmsV2Fetch<T>(path: string, tags?: string[]): Promise<T> {
|
|
101
|
+
const url = `${CMS_V2_URL}${path}`;
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
104
|
+
const res = await fetch(url, {
|
|
105
|
+
headers: { "X-API-TOKEN": CMS_API_TOKEN },
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
next: { revalidate: 60, tags: tags || [] },
|
|
108
|
+
});
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
if (!res.ok) throw new Error(`CMS v2 API error: ${res.status} for ${path}`);
|
|
111
|
+
const json = await res.json();
|
|
112
|
+
return json.data ?? json;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** List content items of a specific type (paginated). */
|
|
116
|
+
export async function getContentByType(
|
|
117
|
+
typeName: string,
|
|
118
|
+
page = 1,
|
|
119
|
+
size = 20,
|
|
120
|
+
filters?: Record<string, string>
|
|
121
|
+
): Promise<PaginatedResponse<ContentBlock>> {
|
|
122
|
+
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
|
123
|
+
if (filters) {
|
|
124
|
+
for (const [k, v] of Object.entries(filters)) params.set(`filter.${k}`, v);
|
|
125
|
+
}
|
|
126
|
+
return cmsV2Fetch<PaginatedResponse<ContentBlock>>(
|
|
127
|
+
`/public/content/${encodeURIComponent(typeName)}?${params}`,
|
|
128
|
+
[`content-${typeName}`]
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get a single content item by its slug. */
|
|
133
|
+
export async function getContentBySlug(
|
|
134
|
+
typeName: string,
|
|
135
|
+
slug: string
|
|
136
|
+
): Promise<ContentBlock> {
|
|
137
|
+
return cmsV2Fetch<ContentBlock>(
|
|
138
|
+
`/public/content/${encodeURIComponent(typeName)}/slug/${encodeURIComponent(slug)}`,
|
|
139
|
+
[`content-${typeName}-${slug}`]
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
96
143
|
export async function searchContent(
|
|
97
144
|
query: string,
|
|
98
145
|
type?: string,
|