create-landing-app 0.2.8 → 0.3.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/dist/prompts.js +7 -3
- package/dist/scaffold.js +5 -2
- package/package.json +1 -1
- package/templates/nextjs/base/.dockerignore +6 -0
- package/templates/nextjs/base/.editorconfig +15 -0
- package/templates/nextjs/base/.env.example +9 -2
- package/templates/nextjs/base/.husky/pre-push +8 -10
- package/templates/nextjs/base/CLAUDE.md +169 -0
- package/templates/nextjs/base/Dockerfile +3 -9
- package/templates/nextjs/base/Makefile +25 -0
- package/templates/nextjs/base/app/layout.tsx +6 -9
- package/templates/nextjs/base/app/sitemap.ts +15 -0
- package/templates/nextjs/base/commitlint.config.mjs +6 -22
- package/templates/nextjs/base/components/navs/navbar-mobile.tsx +60 -27
- package/templates/nextjs/base/components/navs/navbar.tsx +9 -2
- package/templates/nextjs/base/components/ui/checkbox.tsx +26 -0
- package/templates/nextjs/base/components/ui/input.tsx +21 -0
- package/templates/nextjs/base/components/ui/radio-group.tsx +36 -0
- package/templates/nextjs/base/components/ui/select.tsx +139 -0
- package/templates/nextjs/base/components/ui/sheet.tsx +139 -0
- package/templates/nextjs/base/components/ui/tabs.tsx +53 -0
- package/templates/nextjs/base/components/ui/textarea.tsx +20 -0
- package/templates/nextjs/base/docker-compose.yml +9 -0
- package/templates/nextjs/base/eslint.config.mjs +5 -9
- package/templates/nextjs/base/next.config.ts +4 -0
- package/templates/nextjs/base/package.json +7 -4
- package/templates/nextjs/base/styles/theme.css +2 -0
- package/templates/nextjs/base/tsconfig.json +2 -2
- package/templates/nextjs/optional/analytics/files/components/analytics.tsx +16 -0
- package/templates/nextjs/optional/analytics/files/components/web-vitals.tsx +16 -0
- package/templates/nextjs/optional/analytics/inject/app__layout.tsx +7 -0
- package/templates/nextjs/optional/analytics/pkg.json +5 -0
- package/templates/nextjs/optional/dark-mode/files/components/theme-toggle.tsx +21 -0
- package/templates/nextjs/optional/dark-mode/inject/app__layout.tsx +8 -0
- package/templates/nextjs/optional/dark-mode/pkg.json +5 -0
- package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar-mobile.tsx +60 -26
- package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar.tsx +8 -2
- package/templates/nextjs/optional/i18n-dict/files/{middleware.ts → proxy.ts} +8 -2
- package/templates/nextjs/optional/i18n-dict/inject/app__layout.tsx +34 -0
- package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/(list)/[category]/main-page.tsx +15 -0
- package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/(list)/[category]/page.tsx +38 -0
- package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/(list)/layout.tsx +28 -0
- package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/detail/[slugNews]/blog-detail-view.tsx +122 -0
- package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/detail/[slugNews]/page.tsx +73 -0
- package/templates/nextjs/optional/sections/blog/files/app/api/blogs/route.ts +14 -0
- package/templates/nextjs/optional/sections/blog/files/components/blogs/blog-component.tsx +58 -0
- package/templates/nextjs/optional/sections/blog/files/components/blogs/blog-view-desktop.tsx +121 -0
- package/templates/nextjs/optional/sections/blog/files/components/blogs/blog-view-mobile.tsx +90 -0
- package/templates/nextjs/optional/sections/blog/files/components/navs/layout-blogs.tsx +51 -0
- package/templates/nextjs/optional/sections/blog/files/components/sections/blog-section-view.tsx +171 -0
- package/templates/nextjs/optional/sections/blog/files/components/sections/blog-section.tsx +13 -174
- package/templates/nextjs/optional/sections/blog/files/hooks/use-mobile.ts +19 -0
- package/templates/nextjs/optional/sections/blog/files/lib/blog-api.ts +336 -0
- package/templates/nextjs/optional/sections/blog/files/lib/sanitize.ts +25 -0
- package/templates/nextjs/optional/sections/blog/files/styles/prose.css +40 -0
- package/templates/nextjs/optional/sections/blog/inject/constants__common.ts +1 -1
- package/templates/nextjs/optional/sections/blog/pkg.json +10 -0
|
@@ -11,12 +11,15 @@ import { cn } from "@/lib/utils";
|
|
|
11
11
|
export default function Navbar() {
|
|
12
12
|
const dict = useDictionary();
|
|
13
13
|
const [isVisible, setIsVisible] = useState(true);
|
|
14
|
+
const [isScrolled, setIsScrolled] = useState(false);
|
|
14
15
|
const lastScrollY = useRef(0);
|
|
15
16
|
|
|
16
17
|
useEffect(() => {
|
|
17
18
|
const controlNavbar = () => {
|
|
18
19
|
const currentScrollY = window.scrollY;
|
|
19
20
|
|
|
21
|
+
setIsScrolled(currentScrollY > 10);
|
|
22
|
+
|
|
20
23
|
if (currentScrollY < lastScrollY.current || currentScrollY < 10) {
|
|
21
24
|
setIsVisible(true);
|
|
22
25
|
} else {
|
|
@@ -33,8 +36,11 @@ export default function Navbar() {
|
|
|
33
36
|
return (
|
|
34
37
|
<header
|
|
35
38
|
className={cn(
|
|
36
|
-
"fixed top-0 z-50 w-full
|
|
37
|
-
isVisible ? "translate-y-0" : "-translate-y-[calc(100%+20px)]"
|
|
39
|
+
"fixed top-0 z-50 w-full transition-all duration-700 ease-in-out",
|
|
40
|
+
isVisible ? "translate-y-0" : "-translate-y-[calc(100%+20px)]",
|
|
41
|
+
isScrolled
|
|
42
|
+
? "border-b border-border/40 bg-background/80 backdrop-blur-md shadow-sm"
|
|
43
|
+
: "border-b border-transparent bg-transparent",
|
|
38
44
|
)}
|
|
39
45
|
>
|
|
40
46
|
<div className="content-container flex h-16 items-center justify-between">
|
|
@@ -14,12 +14,18 @@ function getLocale(request: NextRequest): string {
|
|
|
14
14
|
return matchLocale(languages, [...i18n.locales], i18n.defaultLocale);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function
|
|
17
|
+
export function proxy(request: NextRequest) {
|
|
18
18
|
const { pathname } = request.nextUrl;
|
|
19
19
|
const pathnameHasLocale = i18n.locales.some(
|
|
20
20
|
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
|
|
21
21
|
);
|
|
22
|
-
if (pathnameHasLocale)
|
|
22
|
+
if (pathnameHasLocale) {
|
|
23
|
+
// Forward detected locale to the root layout via response header
|
|
24
|
+
const locale = pathname.split("/")[1];
|
|
25
|
+
const response = NextResponse.next();
|
|
26
|
+
response.headers.set("x-locale", locale);
|
|
27
|
+
return response;
|
|
28
|
+
}
|
|
23
29
|
|
|
24
30
|
const locale = getLocale(request);
|
|
25
31
|
request.nextUrl.pathname = `/${locale}${pathname}`;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Inter } from "next/font/google";
|
|
3
|
+
import { headers } from "next/headers";
|
|
4
|
+
import { Toaster } from "@/components/ui/sonner";
|
|
5
|
+
import Providers from "@/components/providers";
|
|
6
|
+
import { createMetadata } from "@/lib/metadata";
|
|
7
|
+
import "./globals.css";
|
|
8
|
+
// __PROVIDERS_IMPORT__
|
|
9
|
+
|
|
10
|
+
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = createMetadata({
|
|
13
|
+
title: "__PROJECT_NAME__",
|
|
14
|
+
description: "A modern landing page",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
18
|
+
// Locale is set by the i18n middleware via x-locale response header
|
|
19
|
+
const headersList = await headers();
|
|
20
|
+
const lang = headersList.get("x-locale") ?? "en";
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<html lang={lang} suppressHydrationWarning>
|
|
24
|
+
<body className={`${inter.variable} font-sans antialiased`}>
|
|
25
|
+
<Providers>
|
|
26
|
+
{/* __PROVIDERS_WRAP_START__ */}
|
|
27
|
+
{children}
|
|
28
|
+
{/* __PROVIDERS_WRAP_END__ */}
|
|
29
|
+
</Providers>
|
|
30
|
+
<Toaster />
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import BlogComponent from "@/components/blogs/blog-component";
|
|
3
|
+
import type { Blog, BlogCategory } from "@/lib/blog-api";
|
|
4
|
+
|
|
5
|
+
export default function MainPage({
|
|
6
|
+
category,
|
|
7
|
+
categories,
|
|
8
|
+
blogs,
|
|
9
|
+
}: {
|
|
10
|
+
category: string;
|
|
11
|
+
categories: BlogCategory[];
|
|
12
|
+
blogs: Blog[];
|
|
13
|
+
}) {
|
|
14
|
+
return <BlogComponent category={category} categories={categories} blogs={blogs} />;
|
|
15
|
+
}
|
package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/(list)/[category]/page.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getBlogCategories, getBlogsByCategory } from "@/lib/blog-api";
|
|
2
|
+
import { createMetadata } from "@/lib/metadata";
|
|
3
|
+
import { cache } from "react";
|
|
4
|
+
import MainPage from "./main-page";
|
|
5
|
+
|
|
6
|
+
// Cache listing pages — same window as detail pages, tune via NEXT_BLOG_REVALIDATE_SECONDS
|
|
7
|
+
export const revalidate = Number(process.env.NEXT_BLOG_REVALIDATE_SECONDS) || 86400;
|
|
8
|
+
|
|
9
|
+
const getCategories = cache(getBlogCategories);
|
|
10
|
+
|
|
11
|
+
export async function generateMetadata({
|
|
12
|
+
params,
|
|
13
|
+
}: {
|
|
14
|
+
params: Promise<{ category: string; lang: string }>;
|
|
15
|
+
}) {
|
|
16
|
+
const { category } = await params;
|
|
17
|
+
const categories = await getCategories();
|
|
18
|
+
const matched = categories.find((c) => c.slug === category);
|
|
19
|
+
|
|
20
|
+
return createMetadata({
|
|
21
|
+
title: matched ? `Blog | ${matched.name}` : "Blog",
|
|
22
|
+
description: "Insights, tutorials, and updates from our team.",
|
|
23
|
+
path: `/blogs/${category}`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default async function Page({
|
|
28
|
+
params,
|
|
29
|
+
}: {
|
|
30
|
+
params: Promise<{ category: string; lang: string }>;
|
|
31
|
+
}) {
|
|
32
|
+
const { category } = await params;
|
|
33
|
+
const [categories, { blogs }] = await Promise.all([
|
|
34
|
+
getCategories(),
|
|
35
|
+
getBlogsByCategory({ category, pageSize: 9999 }),
|
|
36
|
+
]);
|
|
37
|
+
return <MainPage category={category} categories={categories} blogs={blogs} />;
|
|
38
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import Navbar from "@/components/navs/navbar";
|
|
2
|
+
import FooterSection from "@/components/sections/footer-section";
|
|
3
|
+
import LayoutBlogs from "@/components/navs/layout-blogs";
|
|
4
|
+
import { getBlogCategories } from "@/lib/blog-api";
|
|
5
|
+
|
|
6
|
+
// Nav categories are static data — cache window tunable via NEXT_BLOG_REVALIDATE_SECONDS
|
|
7
|
+
export const revalidate = Number(process.env.NEXT_BLOG_REVALIDATE_SECONDS) || 86400;
|
|
8
|
+
|
|
9
|
+
export default async function Layout({
|
|
10
|
+
children,
|
|
11
|
+
params,
|
|
12
|
+
}: {
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
params: Promise<{ lang: string }>;
|
|
15
|
+
}) {
|
|
16
|
+
// params.lang available for future i18n label lookup
|
|
17
|
+
await params;
|
|
18
|
+
|
|
19
|
+
const categories = await getBlogCategories();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<Navbar />
|
|
24
|
+
<LayoutBlogs categories={categories}>{children}</LayoutBlogs>
|
|
25
|
+
<FooterSection />
|
|
26
|
+
</>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { motion } from "motion/react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import type { Blog, BlogCategory } from "@/lib/blog-api";
|
|
7
|
+
import { sanitizeBlogContent } from "@/lib/sanitize";
|
|
8
|
+
import "@/styles/prose.css";
|
|
9
|
+
|
|
10
|
+
function CategoryBadges({ categoryIds, categories = [] }: { categoryIds?: string[]; categories?: BlogCategory[] }) {
|
|
11
|
+
if (!categoryIds?.length) return null;
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-wrap gap-1">
|
|
14
|
+
{categoryIds.map((id) => {
|
|
15
|
+
const name = categories.find((c) => c.id === id)?.name ?? id;
|
|
16
|
+
return (
|
|
17
|
+
<Button key={id} className="h-[22px] w-fit rounded-[4px] bg-primary text-xs text-primary-foreground hover:bg-primary/80">
|
|
18
|
+
{name}
|
|
19
|
+
</Button>
|
|
20
|
+
);
|
|
21
|
+
})}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Sidebar: related blogs in the same category
|
|
27
|
+
function RelatedBlogs({ blogs, dateFormatter, categories }: { blogs: Blog[]; dateFormatter: Intl.DateTimeFormat; categories: BlogCategory[] }) {
|
|
28
|
+
if (!blogs.length) return null;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<aside className="mt-40 col-span-4 flex flex-col gap-4 max-xl:col-span-1 max-xl:mt-0">
|
|
32
|
+
<h3 className="text-2xl font-semibold text-primary">Related Blogs</h3>
|
|
33
|
+
<div className="flex flex-col gap-6">
|
|
34
|
+
{blogs.slice(0, 5).map((blog) => (
|
|
35
|
+
<Link
|
|
36
|
+
key={blog.slug}
|
|
37
|
+
href={`/blogs/detail/${blog.slug}`}
|
|
38
|
+
className="flex items-start gap-4 hover:opacity-80 transition-opacity"
|
|
39
|
+
>
|
|
40
|
+
<div className="h-[91px] w-[118px] flex-shrink-0">
|
|
41
|
+
{blog.thumbnail ? (
|
|
42
|
+
<img
|
|
43
|
+
src={blog.thumbnail}
|
|
44
|
+
alt={blog.name}
|
|
45
|
+
loading="lazy"
|
|
46
|
+
className="h-full w-full object-cover rounded-[10px]"
|
|
47
|
+
/>
|
|
48
|
+
) : (
|
|
49
|
+
<div className="h-full w-full bg-muted rounded-[10px]" />
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
<div className="flex flex-col gap-1">
|
|
53
|
+
<CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
|
|
54
|
+
<span className="text-sm font-semibold leading-6 text-primary line-clamp-2">
|
|
55
|
+
{blog.name}
|
|
56
|
+
</span>
|
|
57
|
+
<time dateTime={blog.publishedAt} className="text-sm text-muted-foreground">
|
|
58
|
+
{dateFormatter.format(new Date(blog.publishedAt))}
|
|
59
|
+
</time>
|
|
60
|
+
</div>
|
|
61
|
+
</Link>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
</aside>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type BlogDetailViewProps = {
|
|
69
|
+
blog: Blog;
|
|
70
|
+
relatedBlogs: Blog[];
|
|
71
|
+
categories: BlogCategory[];
|
|
72
|
+
lang: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function BlogDetailView({ blog, relatedBlogs, categories, lang }: BlogDetailViewProps) {
|
|
76
|
+
// Locale-aware date formatter — useMemo avoids recreation on every render
|
|
77
|
+
const dateFormatter = useMemo(
|
|
78
|
+
() => new Intl.DateTimeFormat(lang === "en" ? "en-US" : "vi-VN", {
|
|
79
|
+
day: "2-digit", month: "2-digit", year: "numeric",
|
|
80
|
+
}),
|
|
81
|
+
[lang]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const htmlContent = typeof blog.content === "string"
|
|
85
|
+
? blog.content
|
|
86
|
+
: blog.content?.htmlContent ?? "";
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<motion.div
|
|
90
|
+
viewport={{ once: true }}
|
|
91
|
+
initial={{ opacity: 0, y: 40 }}
|
|
92
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
93
|
+
transition={{ duration: 1.1 }}
|
|
94
|
+
className="py-20 grid grid-cols-12 gap-8 max-xl:grid-cols-1 max-md:mt-9"
|
|
95
|
+
>
|
|
96
|
+
{/* Main content */}
|
|
97
|
+
<article className="col-span-8 max-xl:col-span-1 flex flex-col gap-6">
|
|
98
|
+
<div className="flex flex-col gap-3">
|
|
99
|
+
<CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
|
|
100
|
+
<h1 className="text-3xl font-semibold leading-tight text-primary">{blog.name}</h1>
|
|
101
|
+
<time dateTime={blog.publishedAt} className="text-sm text-muted-foreground">
|
|
102
|
+
{dateFormatter.format(new Date(blog.publishedAt))}
|
|
103
|
+
</time>
|
|
104
|
+
<div className="rounded-[4px] bg-muted p-4">
|
|
105
|
+
<p className="text-sm leading-5 text-primary">{blog.description}</p>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
{blog.thumbnail && (
|
|
109
|
+
<img src={blog.thumbnail} alt={blog.name} className="w-full rounded-2xl object-cover max-h-[420px]" />
|
|
110
|
+
)}
|
|
111
|
+
{htmlContent && (
|
|
112
|
+
<div
|
|
113
|
+
className="ProseMirror"
|
|
114
|
+
dangerouslySetInnerHTML={{ __html: sanitizeBlogContent(htmlContent) }}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
</article>
|
|
118
|
+
|
|
119
|
+
<RelatedBlogs blogs={relatedBlogs} dateFormatter={dateFormatter} categories={categories} />
|
|
120
|
+
</motion.div>
|
|
121
|
+
);
|
|
122
|
+
}
|
package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/detail/[slugNews]/page.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import { cache } from "react";
|
|
3
|
+
import { getBlogBySlug, getRelatedBlogs, getBlogCategories, getBlogs } from "@/lib/blog-api";
|
|
4
|
+
import { createMetadata } from "@/lib/metadata";
|
|
5
|
+
import { BlogDetailView } from "./blog-detail-view";
|
|
6
|
+
import Navbar from "@/components/navs/navbar";
|
|
7
|
+
import FooterSection from "@/components/sections/footer-section";
|
|
8
|
+
|
|
9
|
+
// Cache this page's HTML — serve stale + regenerate in background after expiry
|
|
10
|
+
export const revalidate = Number(process.env.NEXT_BLOG_REVALIDATE_SECONDS) || 86400;
|
|
11
|
+
|
|
12
|
+
// Pre-render top 20 blog detail pages at build time; remaining slugs render on first visit then get cached
|
|
13
|
+
export async function generateStaticParams() {
|
|
14
|
+
try {
|
|
15
|
+
const blogs = await getBlogs(20);
|
|
16
|
+
return blogs.map((blog) => ({ slugNews: blog.slug }));
|
|
17
|
+
} catch {
|
|
18
|
+
return []; // build succeeds even if API is down
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Deduplicate API calls shared between generateMetadata and Page
|
|
23
|
+
const fetchBlog = cache(getBlogBySlug);
|
|
24
|
+
|
|
25
|
+
export async function generateMetadata({
|
|
26
|
+
params,
|
|
27
|
+
}: {
|
|
28
|
+
params: Promise<{ slugNews: string; lang: string }>;
|
|
29
|
+
}) {
|
|
30
|
+
const { slugNews } = await params;
|
|
31
|
+
const blog = await fetchBlog(slugNews);
|
|
32
|
+
|
|
33
|
+
if (!blog) {
|
|
34
|
+
return createMetadata({ title: "Blog", description: "Blog" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return createMetadata({
|
|
38
|
+
title: blog.name,
|
|
39
|
+
description: blog.description,
|
|
40
|
+
image: blog.thumbnail,
|
|
41
|
+
path: `/blogs/detail/${blog.slug}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// RSC: fetch blog + related, guard, pass as plain props to client component
|
|
46
|
+
export default async function BlogDetailPage({
|
|
47
|
+
params,
|
|
48
|
+
}: {
|
|
49
|
+
params: Promise<{ slugNews: string; lang: string }>;
|
|
50
|
+
}) {
|
|
51
|
+
const { slugNews, lang } = await params;
|
|
52
|
+
|
|
53
|
+
const blog = await fetchBlog(slugNews);
|
|
54
|
+
|
|
55
|
+
// Guard — must happen server-side before anything renders
|
|
56
|
+
if (!blog) notFound();
|
|
57
|
+
|
|
58
|
+
// Parallel: categories + related (related needs blog.categoryIds[0] from above)
|
|
59
|
+
const [categories, relatedBlogs] = await Promise.all([
|
|
60
|
+
getBlogCategories(),
|
|
61
|
+
getRelatedBlogs(blog.categoryIds?.[0] ?? "", blog.id ?? ""),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<Navbar />
|
|
67
|
+
<main className="content-container">
|
|
68
|
+
<BlogDetailView blog={blog} relatedBlogs={relatedBlogs} categories={categories} lang={lang} />
|
|
69
|
+
</main>
|
|
70
|
+
<FooterSection />
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getBlogsByCategory } from "@/lib/blog-api";
|
|
3
|
+
|
|
4
|
+
// GET /api/blogs?category=all&page=1&pageSize=9999
|
|
5
|
+
export async function GET(request: NextRequest) {
|
|
6
|
+
const { searchParams } = request.nextUrl;
|
|
7
|
+
const category = searchParams.get("category") ?? "all";
|
|
8
|
+
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
|
9
|
+
const pageSize = parseInt(searchParams.get("pageSize") ?? "9999", 10);
|
|
10
|
+
|
|
11
|
+
const blogs = await getBlogsByCategory({ category, page, pageSize });
|
|
12
|
+
|
|
13
|
+
return NextResponse.json({ blogs });
|
|
14
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useIsMobile } from "@/hooks/use-mobile";
|
|
4
|
+
import type { Blog, BlogCategory } from "@/lib/blog-api";
|
|
5
|
+
import BlogViewDesktop from "./blog-view-desktop";
|
|
6
|
+
import { BlogViewMobile } from "./blog-view-mobile";
|
|
7
|
+
|
|
8
|
+
async function fetchBlogs(category: string): Promise<Blog[]> {
|
|
9
|
+
const params = new URLSearchParams({ category, pageSize: "9999" });
|
|
10
|
+
const res = await fetch(`/api/blogs?${params}`);
|
|
11
|
+
if (!res.ok) throw new Error("Failed to fetch blogs");
|
|
12
|
+
const data = await res.json();
|
|
13
|
+
return data.blogs;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function BlogComponent({ category, categories = [] }: { category: string; categories?: BlogCategory[] }) {
|
|
17
|
+
const isMobile = useIsMobile();
|
|
18
|
+
|
|
19
|
+
const { data, isError, isPending } = useQuery<Blog[]>({
|
|
20
|
+
queryKey: ["/blogs/list-all", { category }],
|
|
21
|
+
queryFn: () => fetchBlogs(category),
|
|
22
|
+
retry: 0,
|
|
23
|
+
refetchOnWindowFocus: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (isPending) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="grid grid-cols-3 gap-8 max-lg:grid-cols-2 max-sm:grid-cols-1">
|
|
29
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
30
|
+
<div key={i} className="flex flex-col gap-3 animate-pulse">
|
|
31
|
+
<div className="h-[200px] w-full rounded-xl bg-muted" />
|
|
32
|
+
<div className="h-4 w-20 rounded bg-muted" />
|
|
33
|
+
<div className="h-5 w-full rounded bg-muted" />
|
|
34
|
+
<div className="h-4 w-3/4 rounded bg-muted" />
|
|
35
|
+
</div>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (isError) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex min-h-[500px] items-center justify-center text-center text-muted-foreground">
|
|
44
|
+
Failed to load blogs. Please try again.
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!data || data.length === 0) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex min-h-[500px] items-center justify-center text-center text-2xl font-bold text-primary">
|
|
52
|
+
No blogs yet
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return isMobile ? <BlogViewMobile data={data} categories={categories} /> : <BlogViewDesktop data={data} categories={categories} />;
|
|
58
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import type { Blog, BlogCategory } from "@/lib/blog-api";
|
|
6
|
+
|
|
7
|
+
function formatDate(date: string) {
|
|
8
|
+
return new Date(date).toLocaleDateString("en-GB");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function Thumbnail({ src, alt, className }: { src?: string; alt?: string; className: string }) {
|
|
12
|
+
return src ? (
|
|
13
|
+
<img src={src} alt={alt ?? ""} loading="lazy" className={`h-full w-full object-cover ${className}`} />
|
|
14
|
+
) : (
|
|
15
|
+
<div className={`h-full w-full bg-muted ${className}`} />
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function CategoryBadges({ categoryIds, categories }: { categoryIds?: string[]; categories: BlogCategory[] }) {
|
|
20
|
+
if (!categoryIds?.length) return null;
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex flex-wrap gap-1">
|
|
23
|
+
{categoryIds.map((id) => (
|
|
24
|
+
<Button key={id} className="h-[22px] w-fit rounded-[4px] bg-primary px-2 text-xs text-primary-foreground hover:bg-primary/80">
|
|
25
|
+
{categories.find((c) => c.id === id)?.name ?? id}
|
|
26
|
+
</Button>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function BlogViewDesktop({ data, categories = [] }: { data: Blog[]; categories?: BlogCategory[] }) {
|
|
33
|
+
const [featured, ...rest] = data;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<motion.div
|
|
37
|
+
viewport={{ once: true }}
|
|
38
|
+
initial={{ opacity: 0, y: 40 }}
|
|
39
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
40
|
+
transition={{ duration: 1.1 }}
|
|
41
|
+
className="flex flex-col gap-[128px] pb-20"
|
|
42
|
+
>
|
|
43
|
+
{/* Featured + Sidebar */}
|
|
44
|
+
<div className="flex justify-between gap-[29px] max-lg:gap-5">
|
|
45
|
+
{/* Featured Blog (Left) */}
|
|
46
|
+
<div className={`flex flex-col gap-2 ${data.length <= 1 ? "w-full" : "w-1/2"}`}>
|
|
47
|
+
<Link
|
|
48
|
+
href={`/blogs/detail/${featured.slug}`}
|
|
49
|
+
className={`w-full ${data.length <= 1 ? "h-[500px]" : "h-[320px]"}`}
|
|
50
|
+
>
|
|
51
|
+
<Thumbnail src={featured.thumbnail} alt={featured.name} className="rounded-[16px]" />
|
|
52
|
+
</Link>
|
|
53
|
+
<div className="mt-1">
|
|
54
|
+
<CategoryBadges categoryIds={featured.categoryIds} categories={categories} />
|
|
55
|
+
</div>
|
|
56
|
+
<Link
|
|
57
|
+
href={`/blogs/detail/${featured.slug}`}
|
|
58
|
+
className="line-clamp-2 text-xl font-semibold leading-[24px] text-primary hover:underline"
|
|
59
|
+
>
|
|
60
|
+
{featured.name}
|
|
61
|
+
</Link>
|
|
62
|
+
<time dateTime={featured.publishedAt} className="text-sm leading-5 text-primary">
|
|
63
|
+
{formatDate(featured.publishedAt)}
|
|
64
|
+
</time>
|
|
65
|
+
<span className="mt-2 text-sm leading-5 text-primary">{featured.description}</span>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Sidebar (Right) — next 3 blogs */}
|
|
69
|
+
{rest.length > 0 && (
|
|
70
|
+
<div className="flex w-1/2 flex-col gap-[30px]">
|
|
71
|
+
{rest.slice(0, 3).map((blog) => (
|
|
72
|
+
<Link
|
|
73
|
+
key={blog.slug}
|
|
74
|
+
href={`/blogs/detail/${blog.slug}`}
|
|
75
|
+
className="flex gap-[30px] max-xl:gap-6 max-lg:gap-4"
|
|
76
|
+
>
|
|
77
|
+
<div className="h-[147px] w-[191px] flex-shrink-0 max-lg:h-[130px] max-lg:w-[169px]">
|
|
78
|
+
<Thumbnail src={blog.thumbnail} alt={blog.name} className="rounded-[10px]" />
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex flex-col gap-2">
|
|
81
|
+
<CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
|
|
82
|
+
<span className="line-clamp-3 text-xl font-semibold leading-[30px] text-primary hover:underline max-xl:text-lg max-lg:text-base">
|
|
83
|
+
{blog.name}
|
|
84
|
+
</span>
|
|
85
|
+
<time dateTime={blog.publishedAt} className="text-sm leading-5 text-primary">
|
|
86
|
+
{formatDate(blog.publishedAt)}
|
|
87
|
+
</time>
|
|
88
|
+
</div>
|
|
89
|
+
</Link>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Grid — blogs beyond index 4 */}
|
|
96
|
+
{data.length > 4 && (
|
|
97
|
+
<div className="flex flex-col gap-[32px]">
|
|
98
|
+
<span className="text-2xl font-semibold leading-8 text-primary">More Blogs</span>
|
|
99
|
+
<div className="grid grid-cols-4 gap-8 max-lg:grid-cols-2">
|
|
100
|
+
{data.slice(4).map((blog) => (
|
|
101
|
+
<Link key={blog.slug} href={`/blogs/detail/${blog.slug}`} className="flex flex-col gap-[30px]">
|
|
102
|
+
<div className="h-[220px] w-full max-lg:h-[160px]">
|
|
103
|
+
<Thumbnail src={blog.thumbnail} alt={blog.name} className="rounded-[16px]" />
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex flex-col gap-2">
|
|
106
|
+
<CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
|
|
107
|
+
<span className="line-clamp-2 text-xl font-semibold leading-[30px] text-primary max-xl:text-lg max-lg:text-base">
|
|
108
|
+
{blog.name}
|
|
109
|
+
</span>
|
|
110
|
+
<time dateTime={blog.publishedAt} className="text-sm leading-5 text-primary">
|
|
111
|
+
{formatDate(blog.publishedAt)}
|
|
112
|
+
</time>
|
|
113
|
+
</div>
|
|
114
|
+
</Link>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</motion.div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import type { Blog, BlogCategory } from "@/lib/blog-api";
|
|
6
|
+
|
|
7
|
+
function formatDate(date: string) {
|
|
8
|
+
return new Date(date).toLocaleDateString("en-GB");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function CategoryBadges({ categoryIds, categories }: { categoryIds?: string[]; categories: BlogCategory[] }) {
|
|
12
|
+
if (!categoryIds?.length) return null;
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex flex-wrap gap-1">
|
|
15
|
+
{categoryIds.map((id) => (
|
|
16
|
+
<Button key={id} className="h-[22px] w-fit rounded-[4px] bg-primary px-2 text-xs text-primary-foreground hover:bg-primary/80">
|
|
17
|
+
{categories.find((c) => c.id === id)?.name ?? id}
|
|
18
|
+
</Button>
|
|
19
|
+
))}
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function BlogViewMobile({ data, categories = [] }: { data: Blog[]; categories?: BlogCategory[] }) {
|
|
25
|
+
const [featured, ...rest] = data;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<motion.div
|
|
29
|
+
viewport={{ once: true }}
|
|
30
|
+
initial={{ opacity: 0, y: 40 }}
|
|
31
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
32
|
+
transition={{ duration: 1.1 }}
|
|
33
|
+
className="flex flex-col gap-6 pb-20"
|
|
34
|
+
>
|
|
35
|
+
{/* Featured Blog */}
|
|
36
|
+
<div className="flex flex-col gap-2">
|
|
37
|
+
<Link href={`/blogs/detail/${featured.slug}`} className="h-[230px] w-full">
|
|
38
|
+
{featured.thumbnail ? (
|
|
39
|
+
<img
|
|
40
|
+
src={featured.thumbnail}
|
|
41
|
+
alt={featured.name}
|
|
42
|
+
loading="eager"
|
|
43
|
+
className="h-full w-full object-cover rounded-[16px]"
|
|
44
|
+
/>
|
|
45
|
+
) : (
|
|
46
|
+
<div className="h-full w-full bg-muted rounded-[16px]" />
|
|
47
|
+
)}
|
|
48
|
+
</Link>
|
|
49
|
+
<CategoryBadges categoryIds={featured.categoryIds} categories={categories} />
|
|
50
|
+
<Link
|
|
51
|
+
href={`/blogs/detail/${featured.slug}`}
|
|
52
|
+
className="text-xl font-semibold leading-[30px] text-primary hover:underline"
|
|
53
|
+
>
|
|
54
|
+
{featured.name}
|
|
55
|
+
</Link>
|
|
56
|
+
<time dateTime={featured.publishedAt} className="text-sm text-primary">
|
|
57
|
+
{formatDate(featured.publishedAt)}
|
|
58
|
+
</time>
|
|
59
|
+
<span className="text-sm text-primary">{featured.description}</span>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Rest of blogs */}
|
|
63
|
+
<div className="flex flex-col gap-6">
|
|
64
|
+
{rest.map((blog) => (
|
|
65
|
+
<Link key={blog.slug} href={`/blogs/detail/${blog.slug}`} className="flex items-start gap-4">
|
|
66
|
+
<div className="h-[91px] w-[118px] flex-shrink-0">
|
|
67
|
+
{blog.thumbnail ? (
|
|
68
|
+
<img
|
|
69
|
+
src={blog.thumbnail}
|
|
70
|
+
alt={blog.name}
|
|
71
|
+
loading="lazy"
|
|
72
|
+
className="h-full w-full object-cover rounded-[10px]"
|
|
73
|
+
/>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="h-full w-full bg-muted rounded-[10px]" />
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
<div className="flex flex-col gap-2">
|
|
79
|
+
<CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
|
|
80
|
+
<span className="text-sm font-semibold text-primary hover:underline">{blog.name}</span>
|
|
81
|
+
<time dateTime={blog.publishedAt} className="text-xs text-primary">
|
|
82
|
+
{formatDate(blog.publishedAt)}
|
|
83
|
+
</time>
|
|
84
|
+
</div>
|
|
85
|
+
</Link>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</motion.div>
|
|
89
|
+
);
|
|
90
|
+
}
|