2026-02-19 23:20:19 +09:00
commit 0e21562088
139 changed files with 35467 additions and 0 deletions

332
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,332 @@
import { Suspense } from "react";
import Link from "next/link";
import Image from "next/image";
import { apiFetch } from "@/lib/api";
import { BlogPost, PaginatedResult, TagCloudItem } from "@/lib/types";
import { PostCard } from "@/components/post-card";
import { Pagination } from "@/components/pagination";
import { PostFilterForm } from "@/components/post-filter-form";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Eye, Star, TrendingUp } from "lucide-react";
interface HomeSearchParams {
q?: string;
tags?: string;
category?: string;
sort?: string;
page?: string;
pageSize?: string;
}
async function getFeatured(): Promise<BlogPost[]> {
try {
return await apiFetch<BlogPost[]>("/blog-posts/public/featured");
} catch {
return [];
}
}
async function getPosts(
params: HomeSearchParams
): Promise<PaginatedResult<BlogPost>> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.tags) qs.set("tags", params.tags);
if (params.category) qs.set("category", params.category);
if (params.sort) qs.set("sort", params.sort);
qs.set("page", params.page || "1");
qs.set("pageSize", params.pageSize || "9");
try {
return await apiFetch<PaginatedResult<BlogPost>>(
`/blog-posts/public?${qs.toString()}`
);
} catch {
return { items: [], total: 0, page: 1, pageSize: 9, totalPages: 0 };
}
}
async function getAllPostsForCloud(): Promise<BlogPost[]> {
try {
const result = await apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?pageSize=100"
);
return result.items;
} catch {
return [];
}
}
function buildTagCloud(posts: BlogPost[]): TagCloudItem[] {
const counts: Record<string, number> = {};
for (const post of posts) {
for (const tag of post.tags || []) {
const t = tag.trim();
if (t) counts[t] = (counts[t] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20);
}
function buildCategoryCloud(posts: BlogPost[]): TagCloudItem[] {
const counts: Record<string, number> = {};
for (const post of posts) {
for (const cat of post.categories || []) {
const c = cat.trim();
if (c) counts[c] = (counts[c] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
}
export default async function HomePage({
searchParams,
}: {
searchParams: Promise<HomeSearchParams>;
}) {
const params = await searchParams;
const [featured, postsResult, allPosts, popularResult] = await Promise.all([
getFeatured(),
getPosts(params),
getAllPostsForCloud(),
apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?sort=most_viewed&pageSize=5"
).catch(() => ({ items: [] as BlogPost[] })),
]);
const topTags = buildTagCloud(allPosts);
const topCategories = buildCategoryCloud(allPosts);
const popularPosts = (popularResult as PaginatedResult<BlogPost>).items || [];
const { items: posts, page, totalPages, total } = postsResult;
return (
<div className="space-y-6">
{/* ── Hero ──────────────────────────────────────────────────────────── */}
<section className="rounded-2xl border bg-gradient-to-r from-background via-muted/30 to-background p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-2xl">
<h1 className="text-3xl font-bold tracking-tight">
The blog is all about web dev tech
</h1>
<p className="mt-2 text-muted-foreground">
Fast backend patterns, frontend craft, and product lessons from
shipping real systems.
</p>
</div>
<div className="flex flex-wrap gap-2">
{topCategories.slice(0, 6).map((c) => (
<Link
key={c.name}
href={`/?category=${encodeURIComponent(c.name)}`}
>
<Badge
variant="secondary"
className="cursor-pointer hover:bg-secondary/80"
>
{c.name} ({c.count})
</Badge>
</Link>
))}
</div>
</div>
</section>
{/* ── Featured posts ─────────────────────────────────────────────────── */}
{featured.length > 0 && (
<section className="rounded-2xl border bg-gradient-to-r from-background via-sky-50 to-emerald-50 dark:via-sky-950/20 dark:to-emerald-950/20 p-5">
<div className="flex items-center gap-2 mb-4">
<Star className="h-4 w-4 text-amber-500 fill-amber-500" />
<h2 className="font-semibold text-lg">Featured</h2>
</div>
<div className="grid gap-4 lg:grid-cols-[1.5fr_1fr]">
{/* Primary featured */}
<article className="rounded-xl border bg-background/80 p-5">
<Badge className="mb-3 bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 border-amber-200">
Featured pick
</Badge>
{featured[0].featuredImageUrl && (
<Link href={`/blog/${featured[0].slug}`} className="block mb-4">
<div className="relative h-52 w-full overflow-hidden rounded-lg">
<Image
src={featured[0].featuredImageUrl}
alt={featured[0].featuredImageAlt || featured[0].title}
fill
className="object-cover"
sizes="(max-width: 1024px) 100vw, 60vw"
priority
/>
</div>
</Link>
)}
<Link href={`/blog/${featured[0].slug}`} className="group">
<h2 className="text-2xl font-bold leading-tight group-hover:text-primary transition-colors">
{featured[0].title}
</h2>
</Link>
<p className="mt-2 text-muted-foreground">
{featured[0].excerpt}
</p>
<p className="mt-2 text-sm text-muted-foreground flex items-center gap-1">
<Eye className="h-3 w-3" />
{featured[0].views.toLocaleString()} views
</p>
</article>
{/* Secondary featured */}
<div className="grid gap-3">
{featured.slice(1).map((post) => (
<article
key={post.id}
className="rounded-xl border bg-background/80 p-4 flex gap-3"
>
{post.featuredImageUrl && (
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-lg">
<Image
src={post.featuredImageUrl}
alt={post.featuredImageAlt || post.title}
fill
className="object-cover"
sizes="64px"
/>
</div>
)}
<div className="min-w-0">
<Link href={`/blog/${post.slug}`} className="group">
<h3 className="font-semibold leading-tight group-hover:text-primary transition-colors line-clamp-2">
{post.title}
</h3>
</Link>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{post.excerpt}
</p>
</div>
</article>
))}
</div>
</div>
</section>
)}
{/* ── Main content + sidebar ─────────────────────────────────────────── */}
<div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
<div className="space-y-4">
{/* Filter form */}
<Suspense fallback={null}>
<PostFilterForm
topCategories={topCategories}
currentQ={params.q || ""}
currentCategory={params.category || ""}
currentSort={params.sort || "newest"}
currentTags={params.tags || ""}
/>
</Suspense>
{/* Post grid */}
{posts.length === 0 ? (
<div className="rounded-xl border bg-muted/30 p-8 text-center">
<p className="text-muted-foreground">
No posts found for this filter.
</p>
<Button asChild variant="link" className="mt-2">
<Link href="/">Clear filters</Link>
</Button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
{/* Pagination */}
<Suspense fallback={null}>
<Pagination page={page} totalPages={totalPages} total={total} />
</Suspense>
</div>
{/* Sidebar */}
<aside className="space-y-4">
{/* Popular Posts */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4" /> Popular Posts
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{popularPosts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm font-medium hover:bg-muted transition-colors"
>
<span className="line-clamp-1 mr-2">{post.title}</span>
<span className="text-muted-foreground shrink-0 flex items-center gap-1">
<Eye className="h-3 w-3" />
{post.views.toLocaleString()}
</span>
</Link>
))}
</CardContent>
</Card>
{/* Newsletter */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Newsletter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
One practical note every Friday.
</p>
<div className="flex flex-col gap-2">
<Input type="email" placeholder="you@example.com" />
<Button className="w-full">Join</Button>
</div>
</CardContent>
</Card>
{/* Tag Cloud */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Tag Cloud</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{topTags.map((tag) => (
<Link
key={tag.name}
href={`/?tags=${encodeURIComponent(tag.name)}`}
>
<Badge
variant="outline"
className="cursor-pointer hover:bg-muted transition-colors"
>
#{tag.name} ({tag.count})
</Badge>
</Link>
))}
</div>
</CardContent>
</Card>
<Separator />
<p className="text-xs text-center text-muted-foreground">
Built with NestJS + Next.js 16 + shadcn/ui
</p>
</aside>
</div>
</div>
);
}