From 3bdb2e9f5beb95be3e8fd7d57f2df3dadde301f3 Mon Sep 17 00:00:00 2001 From: binhkid2 Date: Wed, 18 Feb 2026 22:27:05 +0900 Subject: [PATCH] seem good --- frontend/app/auth/page.tsx | 439 ++++++++++++++++++ frontend/app/blog/[slug]/page.tsx | 308 ++++++++++++ frontend/app/blog/[slug]/view-tracker.tsx | 13 + .../dashboard/components/create-post-form.tsx | 200 ++++++++ .../app/dashboard/components/posts-table.tsx | 293 ++++++++++++ .../app/dashboard/components/users-table.tsx | 191 ++++++++ frontend/app/dashboard/page.tsx | 204 ++++++++ frontend/app/globals.css | 1 + frontend/app/layout.tsx | 21 +- frontend/app/page.tsx | 332 ++++++++++++- frontend/components/navbar.tsx | 74 +++ frontend/components/pagination.tsx | 72 +++ frontend/components/post-card.tsx | 72 +++ frontend/components/post-filter-form.tsx | 137 ++++++ frontend/components/ui/alert-dialog.tsx | 196 ++++++++ frontend/lib/api.ts | 95 ++++ frontend/lib/auth.tsx | 136 ++++++ frontend/lib/types.ts | 86 ++++ frontend/next.config.ts | 17 +- frontend/package-lock.json | 42 +- frontend/package.json | 2 + src/main.ts | 8 + 22 files changed, 2925 insertions(+), 14 deletions(-) create mode 100644 frontend/app/auth/page.tsx create mode 100644 frontend/app/blog/[slug]/page.tsx create mode 100644 frontend/app/blog/[slug]/view-tracker.tsx create mode 100644 frontend/app/dashboard/components/create-post-form.tsx create mode 100644 frontend/app/dashboard/components/posts-table.tsx create mode 100644 frontend/app/dashboard/components/users-table.tsx create mode 100644 frontend/app/dashboard/page.tsx create mode 100644 frontend/components/navbar.tsx create mode 100644 frontend/components/pagination.tsx create mode 100644 frontend/components/post-card.tsx create mode 100644 frontend/components/post-filter-form.tsx create mode 100644 frontend/components/ui/alert-dialog.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/lib/auth.tsx create mode 100644 frontend/lib/types.ts diff --git a/frontend/app/auth/page.tsx b/frontend/app/auth/page.tsx new file mode 100644 index 0000000..b0ac2c1 --- /dev/null +++ b/frontend/app/auth/page.tsx @@ -0,0 +1,439 @@ +"use client"; + +import { useState, useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/lib/auth"; +import { clientFetch } from "@/lib/api"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Eye, EyeOff, Loader2, LogIn, UserPlus, KeyRound, Mail } from "lucide-react"; +import { API_URL } from "@/lib/api"; +import { UserRole } from "@/lib/types"; + +// Default export wraps the inner component in Suspense (required by Next.js +// when useSearchParams() is used inside a "use client" page). +export default function AuthPage() { + return ( + + + + ); +} + +function AuthPageInner() { + const { currentUser, login, register, refresh } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const resetToken = searchParams.get("token") || ""; + const initialTab = resetToken ? "reset" : "signin"; + + const [loading, setLoading] = useState(false); + const [showPwd, setShowPwd] = useState(false); + + // Redirect if already logged in + useEffect(() => { + if (currentUser) router.replace("/dashboard"); + }, [currentUser, router]); + + // ── Sign In ─────────────────────────────────────────────────────────────── + const handleSignIn = async (e: React.FormEvent) => { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const email = fd.get("email") as string; + const password = fd.get("password") as string; + setLoading(true); + try { + await login(email, password); + toast.success("Welcome back!"); + router.push("/dashboard"); + router.refresh(); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Login failed"); + } finally { + setLoading(false); + } + }; + + // ── Sign Up ─────────────────────────────────────────────────────────────── + const handleSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const name = fd.get("name") as string; + const email = fd.get("email") as string; + const password = fd.get("password") as string; + setLoading(true); + try { + await register(name, email, password); + toast.success("Account created! Welcome!"); + router.push("/dashboard"); + router.refresh(); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Registration failed"); + } finally { + setLoading(false); + } + }; + + // ── Magic Link ──────────────────────────────────────────────────────────── + const handleMagicLink = async (e: React.FormEvent) => { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const email = fd.get("email") as string; + setLoading(true); + try { + await clientFetch("/auth/magic-link", { + method: "POST", + body: JSON.stringify({ email }), + }); + toast.success("Magic link sent! Check your email."); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Failed to send magic link"); + } finally { + setLoading(false); + } + }; + + // ── Password Reset Request ──────────────────────────────────────────────── + const handleResetRequest = async (e: React.FormEvent) => { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const email = fd.get("email") as string; + setLoading(true); + try { + await clientFetch("/auth/password-reset/request", { + method: "POST", + body: JSON.stringify({ email }), + }); + toast.success("Reset link sent! Check your email."); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Failed"); + } finally { + setLoading(false); + } + }; + + // ── Password Reset Confirm ──────────────────────────────────────────────── + const handleResetConfirm = async (e: React.FormEvent) => { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const token = fd.get("token") as string; + const password = fd.get("password") as string; + setLoading(true); + try { + await clientFetch("/auth/password-reset/confirm", { + method: "POST", + body: JSON.stringify({ token, password }), + }); + toast.success("Password updated! You can now sign in."); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Failed to reset password"); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + Sign In + Sign Up + Reset + + + {/* ── Sign In Tab ─────────────────────────────────────────────────── */} + + + + + Sign In + + + Sign in to your account to access the dashboard. + + + +
+
+ + +
+
+ +
+ + +
+
+ +
+ + + +
+ + +
+ + +
+
+
+ + {/* ── Sign Up Tab ─────────────────────────────────────────────────── */} + + + + + Create Account + + + New sign-ups are assigned the default MEMBER role. + + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+
+
+
+ + {/* ── Reset Password Tab ──────────────────────────────────────────── */} + + + + + Reset Password + + + + {/* Step 1: Request */} +
+ +
+ + +
+
+ + + + {/* Step 2: Confirm */} +
+ + +
+ + +
+ +
+
+
+
+
+ + {/* ── Demo credentials card ─────────────────────────────────────────── */} + + +
+

Demo Admin Account

+

+ Email: admin@gmail.com +

+

+ Password: Whatever123$ +

+ +
+ +
+

Role Permissions

+
+

+ MEMBER — View + blog posts only +

+

+ MANAGER — + View all + create (draft only) +

+

+ ADMIN — Full + CRUD access + user management +

+
+
+
+
+
+ ); +} diff --git a/frontend/app/blog/[slug]/page.tsx b/frontend/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..68adb5e --- /dev/null +++ b/frontend/app/blog/[slug]/page.tsx @@ -0,0 +1,308 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import Image from "next/image"; +import { marked } from "marked"; +import { apiFetch } from "@/lib/api"; +import { BlogPost, PaginatedResult, TagCloudItem } from "@/lib/types"; +import { ViewTracker } from "./view-tracker"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Eye, TrendingUp, ArrowLeft, Calendar } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface Props { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ params }: Props) { + const { slug } = await params; + try { + const { post } = await apiFetch<{ post: BlogPost }>( + `/blog-posts/public/${slug}` + ); + return { + title: post.title, + description: post.excerpt, + openGraph: { + title: post.title, + description: post.excerpt, + images: post.featuredImageUrl ? [post.featuredImageUrl] : [], + }, + }; + } catch { + return { title: "Post not found" }; + } +} + +async function getPost(slug: string): Promise { + try { + const data = await apiFetch<{ success: boolean; post: BlogPost }>( + `/blog-posts/public/${slug}` + ); + return data.post; + } catch { + return null; + } +} + +async function getRelatedPosts(post: BlogPost): Promise { + try { + // Get posts with shared category + const cat = post.categories[0]; + if (cat) { + const result = await apiFetch>( + `/blog-posts/public?category=${encodeURIComponent(cat)}&pageSize=4` + ); + const filtered = result.items.filter((p) => p.id !== post.id); + if (filtered.length > 0) return filtered.slice(0, 4); + } + // Fallback: latest + const result = await apiFetch>( + "/blog-posts/public?pageSize=4" + ); + return result.items.filter((p) => p.id !== post.id).slice(0, 4); + } catch { + return []; + } +} + +async function getPopularPosts(excludeId: string): Promise { + try { + const result = await apiFetch>( + "/blog-posts/public?sort=most_viewed&pageSize=6" + ); + return result.items.filter((p) => p.id !== excludeId).slice(0, 5); + } catch { + return []; + } +} + +async function getTopTags(): Promise { + try { + const result = await apiFetch>( + "/blog-posts/public?pageSize=100" + ); + const counts: Record = {}; + for (const post of result.items) { + 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); + } catch { + return []; + } +} + +export default async function BlogDetailPage({ params }: Props) { + const { slug } = await params; + const post = await getPost(slug); + + if (!post) return notFound(); + + const [relatedPosts, popularPosts, topTags] = await Promise.all([ + getRelatedPosts(post), + getPopularPosts(post.id), + getTopTags(), + ]); + + const contentHtml = + post.contentFormat === "markdown" + ? await marked(post.content) + : post.content; + + const publishedDate = new Date(post.createdAt).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + + return ( +
+ + + {/* ── Back button ────────────────────────────────────────────────────── */} + + + {/* ── Article ────────────────────────────────────────────────────────── */} +
+ {/* Categories */} + {post.categories.length > 0 && ( +
+ {post.categories.map((cat) => ( + + {cat} + + ))} +
+ )} + +

{post.title}

+ + {post.excerpt && ( +

{post.excerpt}

+ )} + + {/* Meta */} +
+ + {publishedDate} + + + {post.views.toLocaleString()} views + + {post.author && ( + by {post.author.name || post.author.email} + )} +
+ + {/* Featured image */} + {post.featuredImageUrl && ( +
+ {post.featuredImageAlt +
+ )} + + {/* Tags */} + {post.tags.length > 0 && ( +
+ {post.tags.map((tag) => ( + + + #{tag} + + + ))} +
+ )} + + + + {/* Content */} +
+
+ + {/* ── Related + Sidebar ──────────────────────────────────────────────── */} +
+ {/* Related posts */} +
+

More from the blog

+ {relatedPosts.length > 0 ? ( +
+ {relatedPosts.map((related) => ( +
+ {related.categories.length > 0 && ( +
+ {related.categories.slice(0, 2).map((cat) => ( + + {cat} + + ))} +
+ )} + +

+ {related.title} +

+ +

+ {related.excerpt} +

+

+ {related.views.toLocaleString()} +

+
+ ))} +
+ ) : ( +

+ No related posts yet. +

+ )} +
+ + {/* Sidebar */} + +
+
+ ); +} diff --git a/frontend/app/blog/[slug]/view-tracker.tsx b/frontend/app/blog/[slug]/view-tracker.tsx new file mode 100644 index 0000000..485736a --- /dev/null +++ b/frontend/app/blog/[slug]/view-tracker.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useEffect } from "react"; +import { clientFetch } from "@/lib/api"; + +export function ViewTracker({ slug }: { slug: string }) { + useEffect(() => { + clientFetch(`/blog-posts/public/${slug}/view`, { method: "POST" }).catch( + () => {} + ); + }, [slug]); + return null; +} diff --git a/frontend/app/dashboard/components/create-post-form.tsx b/frontend/app/dashboard/components/create-post-form.tsx new file mode 100644 index 0000000..f3bb29a --- /dev/null +++ b/frontend/app/dashboard/components/create-post-form.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState } from "react"; +import { clientFetch } from "@/lib/api"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Loader2, PlusCircle } from "lucide-react"; +import { PostStatus, ContentFormat, UserRole } from "@/lib/types"; + +interface Props { + userRole: UserRole; + onCreated: () => void; +} + +export function CreatePostForm({ userRole, onCreated }: Props) { + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState(PostStatus.DRAFT); + const [contentFormat, setContentFormat] = useState( + ContentFormat.MARKDOWN + ); + const [isFeatured, setIsFeatured] = useState("false"); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const body = { + title: fd.get("title"), + slug: fd.get("slug") || undefined, + excerpt: fd.get("excerpt") || undefined, + content: fd.get("content"), + contentFormat, + status: userRole === UserRole.ADMIN ? status : PostStatus.DRAFT, + isFeatured: isFeatured === "true", + categories: fd.get("categories") || undefined, + tags: fd.get("tags") || undefined, + featuredImageUrl: fd.get("featuredImageUrl") || undefined, + featuredImageAlt: fd.get("featuredImageAlt") || undefined, + }; + + setLoading(true); + try { + await clientFetch("/blog-posts", { + method: "POST", + body: JSON.stringify(body), + }); + toast.success("Post created!"); + (e.target as HTMLFormElement).reset(); + onCreated(); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Failed to create post"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Create New Post +

+ {userRole !== UserRole.ADMIN && ( + + Status forced to Draft (Manager) + + )} +
+ +
+
+ + +
+
+ + +
+
+ +
+ +