first commit

This commit is contained in:
dela
2026-02-11 19:46:38 +08:00
parent 1c90905716
commit 08ffa06eb4
40 changed files with 4330 additions and 118 deletions

View File

@@ -0,0 +1,132 @@
"use client";
import { useState, useEffect, useCallback, use } from "react";
import { useRouter } from "next/navigation";
import { Container } from "@/components/layout/Container";
import { Button } from "@/components/ui/Button";
import { Skeleton } from "@/components/ui/Skeleton";
import { MarkdownEditor } from "@/components/editor/MarkdownEditor";
import { ImageUpload } from "@/components/editor/ImageUpload";
import { PostContent } from "@/components/post/PostContent";
import { api } from "@/lib/api";
interface EditPostPageProps {
params: Promise<{ id: string }>;
}
export default function EditPostEditor({ params }: EditPostPageProps) {
const { id } = use(params);
const router = useRouter();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [published, setPublished] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
async function fetchPost() {
try {
const post = await api.getPost(id);
setTitle(post.title);
setContent(post.content);
setPublished(post.published);
} catch {
router.push("/admin");
} finally {
setLoading(false);
}
}
fetchPost();
}, [id, router]);
const handleSave = useCallback(async () => {
if (!title.trim()) return;
setSaving(true);
try {
await api.updatePost(id, {
title,
content,
published,
});
setSaved(true);
} catch {
alert("Failed to save post");
} finally {
setSaving(false);
}
}, [id, title, content, published]);
const handleImageUpload = (url: string) => {
const imageMarkdown = `![](${url})\n`;
setContent((prev) => prev + imageMarkdown);
};
useEffect(() => {
if (saved) {
const timer = setTimeout(() => setSaved(false), 3000);
return () => clearTimeout(timer);
}
}, [saved]);
if (loading) {
return (
<Container className="py-12">
<Skeleton className="h-12 w-1/2 mb-8" />
<Skeleton className="h-[60vh]" />
</Container>
);
}
return (
<Container className="py-12">
<div className="flex items-center justify-between mb-8">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
className="text-4xl font-bold tracking-tighter bg-transparent focus:outline-none placeholder:text-neutral-300 w-full"
/>
<div className="flex items-center gap-4 ml-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={published}
onChange={(e) => setPublished(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm tracking-wide">Publish</span>
</label>
<Button onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? "Saving..." : saved ? "Saved." : "Save"}
</Button>
</div>
</div>
<div className="mb-6">
<ImageUpload onUpload={handleImageUpload} />
</div>
<div className="grid grid-cols-2 gap-8 h-[calc(100vh-280px)]">
<div className="border border-neutral-200 p-6 overflow-auto">
<MarkdownEditor value={content} onChange={setContent} />
</div>
<div className="border border-neutral-200 p-6 overflow-auto bg-neutral-50">
<div className="prose prose-sm max-w-none">
{content ? (
<PostContent content={content} />
) : (
<p className="text-neutral-400">Preview will appear here...</p>
)}
</div>
</div>
</div>
</Container>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Container } from "@/components/layout/Container";
import { Button } from "@/components/ui/Button";
import { MarkdownEditor } from "@/components/editor/MarkdownEditor";
import { ImageUpload } from "@/components/editor/ImageUpload";
import { PostContent } from "@/components/post/PostContent";
import { api } from "@/lib/api";
export default function NewPostEditor() {
const router = useRouter();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [published, setPublished] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const handleSave = useCallback(async () => {
if (!title.trim()) return;
setSaving(true);
try {
const post = await api.createPost({
title,
content,
published,
});
setSaved(true);
setTimeout(() => {
router.push(`/admin/editor/${post.id}`);
}, 1000);
} catch {
alert("Failed to save post");
} finally {
setSaving(false);
}
}, [title, content, published, router]);
const handleImageUpload = (url: string) => {
const imageMarkdown = `![](${url})\n`;
setContent((prev) => prev + imageMarkdown);
};
useEffect(() => {
if (saved) {
const timer = setTimeout(() => setSaved(false), 3000);
return () => clearTimeout(timer);
}
}, [saved]);
return (
<Container className="py-12">
<div className="flex items-center justify-between mb-8">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
className="text-4xl font-bold tracking-tighter bg-transparent focus:outline-none placeholder:text-neutral-300 w-full"
/>
<div className="flex items-center gap-4 ml-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={published}
onChange={(e) => setPublished(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm tracking-wide">Publish</span>
</label>
<Button onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? "Saving..." : saved ? "Saved." : "Save"}
</Button>
</div>
</div>
<div className="mb-6">
<ImageUpload onUpload={handleImageUpload} />
</div>
<div className="grid grid-cols-2 gap-8 h-[calc(100vh-280px)]">
<div className="border border-neutral-200 p-6 overflow-auto">
<MarkdownEditor value={content} onChange={setContent} />
</div>
<div className="border border-neutral-200 p-6 overflow-auto bg-neutral-50">
<div className="prose prose-sm max-w-none">
{content ? (
<PostContent content={content} />
) : (
<p className="text-neutral-400">Preview will appear here...</p>
)}
</div>
</div>
</div>
</Container>
);
}

12
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,12 @@
"use client";
import { type ReactNode } from "react";
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
interface AdminLayoutProps {
children: ReactNode;
}
export default function AdminLayout({ children }: AdminLayoutProps) {
return <ProtectedRoute>{children}</ProtectedRoute>;
}

106
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Container } from "@/components/layout/Container";
import { Button } from "@/components/ui/Button";
import { Skeleton } from "@/components/ui/Skeleton";
import { Tag } from "@/components/ui/Tag";
import { api } from "@/lib/api";
import type { Post } from "@/types/api";
export default function AdminDashboard() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<string | null>(null);
useEffect(() => {
fetchPosts();
}, []);
async function fetchPosts() {
setLoading(true);
try {
const response = await api.getPosts(1, 100);
setPosts(response.posts ?? []);
} catch {
// Handle error silently
} finally {
setLoading(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Are you sure you want to delete this post?")) return;
setDeleting(id);
try {
await api.deletePost(id);
setPosts((prev) => prev.filter((p) => p.id !== id));
} catch {
alert("Failed to delete post");
} finally {
setDeleting(null);
}
}
return (
<Container className="py-24">
<div className="flex items-center justify-between mb-12">
<h1 className="text-5xl font-bold tracking-tighter">Dashboard</h1>
<Link href="/admin/editor">
<Button>New Post</Button>
</Link>
</div>
{loading && (
<div className="space-y-6">
<Skeleton className="h-20" />
<Skeleton className="h-20" />
<Skeleton className="h-20" />
</div>
)}
{!loading && posts.length === 0 && (
<p className="text-neutral-400 text-lg">No posts yet. Create your first one.</p>
)}
{!loading && posts.length > 0 && (
<div className="space-y-6">
{posts.map((post) => (
<div
key={post.id}
className="border border-neutral-200 p-6 flex items-center justify-between"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h2 className="text-xl font-bold tracking-tight truncate">
{post.title || "Untitled"}
</h2>
<Tag>{post.published ? "Published" : "Draft"}</Tag>
</div>
<p className="text-sm text-neutral-500 truncate">
{post.content.slice(0, 100).replace(/[#*`]/g, "")}...
</p>
</div>
<div className="flex items-center gap-4 ml-6">
<Link href={`/admin/editor/${post.id}`}>
<Button size="sm">Edit</Button>
</Link>
<Button
size="sm"
onClick={() => handleDelete(post.id)}
disabled={deleting === post.id}
className="border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
>
{deleting === post.id ? "..." : "Delete"}
</Button>
</div>
</div>
))}
</div>
)}
</Container>
);
}

View File

@@ -1,26 +1,86 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
--foreground: #171717;
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
--spacing-18: 4.5rem;
--spacing-24: 6rem;
--spacing-32: 8rem;
--tracking-tighter: -0.04em;
--tracking-tight: -0.02em;
--tracking-normal: -0.01em;
--tracking-wide: 0.02em;
--tracking-widest: 0.08em;
--leading-relaxed: 1.8;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
.animate-shake {
animation: shake 0.3s ease-in-out;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
/* Syntax highlighting for code blocks */
.hljs {
background: #fafafa !important;
padding: 1.5rem;
overflow-x: auto;
}
.hljs-comment,
.hljs-quote {
color: #6b7280;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-built_in {
color: #1f2937;
font-weight: 600;
}
.hljs-string,
.hljs-attr {
color: #374151;
}
.hljs-number,
.hljs-literal {
color: #4b5563;
}
.hljs-title,
.hljs-section {
color: #111827;
font-weight: 600;
}
.hljs-type,
.hljs-class {
color: #1f2937;
}
/* Prose customizations */
.prose pre {
background-color: #fafafa;
border: 1px solid #e5e5e5;
border-radius: 0;
}
.prose code {
font-family: var(--font-mono);
font-size: 0.875em;
}
.prose img {
border-radius: 0;
}

View File

@@ -1,20 +1,26 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import "highlight.js/styles/github.css";
import { AuthProvider } from "@/lib/auth-context";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
const geistSans = Geist({
variable: "--font-geist-sans",
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "AI Blog",
description: "Minimalist blog powered by AI",
};
export default function RootLayout({
@@ -25,9 +31,13 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${inter.variable} ${jetbrainsMono.variable} font-sans font-medium antialiased bg-white text-black min-h-screen flex flex-col`}
>
{children}
<AuthProvider>
<Header />
<main className="flex-1">{children}</main>
<Footer />
</AuthProvider>
</body>
</html>
);

82
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,82 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Container } from "@/components/layout/Container";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { useAuth } from "@/lib/hooks/useAuth";
export default function LoginPage() {
const router = useRouter();
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login({ email, password });
setSuccess(true);
setTimeout(() => {
router.push("/admin");
}, 1000);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
if (success) {
return (
<Container className="py-24 min-h-[60vh] flex flex-col items-center justify-center">
<p className="text-3xl font-bold tracking-tighter">Welcome</p>
</Container>
);
}
return (
<Container className="py-24">
<div className="max-w-md mx-auto">
<h1 className="text-5xl font-bold tracking-tighter mb-12">Login</h1>
<form onSubmit={handleSubmit} className="space-y-8">
<Input
type="email"
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
error={error ? " " : undefined}
/>
<Input
type="password"
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
error={error ? " " : undefined}
/>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" disabled={loading} className="w-full">
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
</div>
</Container>
);
}

21
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,21 @@
import Link from "next/link";
import { Container } from "@/components/layout/Container";
export default function NotFound() {
return (
<Container className="py-24 min-h-[60vh] flex flex-col items-center justify-center text-center">
<h1 className="text-[12rem] md:text-[20rem] font-bold tracking-tighter leading-none">
404
</h1>
<p className="text-2xl md:text-3xl tracking-tight mt-4">
Signal Lost
</p>
<Link
href="/"
className="mt-12 text-sm font-bold tracking-widest uppercase border-b border-black pb-0.5 hover:border-transparent transition-colors"
>
Return Home
</Link>
</Container>
);
}

View File

@@ -1,65 +1,86 @@
import Image from "next/image";
"use client";
import { useState, useEffect } from "react";
import { Container } from "@/components/layout/Container";
import { PostCard } from "@/components/post/PostCard";
import { PostCardSkeleton } from "@/components/post/PostCardSkeleton";
import { Button } from "@/components/ui/Button";
import { api } from "@/lib/api";
import type { Post } from "@/types/api";
export default function HomePage() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const limit = 10;
useEffect(() => {
async function fetchPosts() {
setLoading(true);
setError(null);
try {
const response = await api.getPosts(page, limit);
setPosts(response.posts ?? []);
setTotal(response.total ?? 0);
} catch {
setError("Could not retrieve latest transmission.");
} finally {
setLoading(false);
}
}
fetchPosts();
}, [page]);
const totalPages = Math.ceil(total / limit);
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<Container className="py-24">
<h1 className="text-6xl md:text-8xl font-bold tracking-tighter mb-24">
Latest
</h1>
{error && (
<p className="text-neutral-400 text-lg">{error}</p>
)}
{loading && (
<>
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</>
)}
{!loading && !error && posts?.length === 0 && (
<p className="text-neutral-400 text-lg">No posts yet.</p>
)}
{!loading && !error && posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-24">
<Button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
Previous
</Button>
<span className="text-sm tracking-widest uppercase text-neutral-500">
{page} / {totalPages}
</span>
<Button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Documentation
</a>
Next
</Button>
</div>
</main>
</div>
)}
</Container>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useState, useEffect, use } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Container } from "@/components/layout/Container";
import { PostContent } from "@/components/post/PostContent";
import { PostMeta } from "@/components/post/PostMeta";
import { Skeleton } from "@/components/ui/Skeleton";
import { TextLink } from "@/components/ui/TextLink";
import { api } from "@/lib/api";
import type { Post } from "@/types/api";
interface PostPageProps {
params: Promise<{ id: string }>;
}
export default function PostPage({ params }: PostPageProps) {
const { id } = use(params);
const router = useRouter();
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
async function fetchPost() {
setLoading(true);
try {
const data = await api.getPost(id);
setPost(data);
} catch {
setError(true);
} finally {
setLoading(false);
}
}
fetchPost();
}, [id]);
if (error) {
router.push("/not-found");
return null;
}
return (
<Container className="py-24">
<TextLink href="/" className="text-sm">
Back to all posts
</TextLink>
{loading && (
<div className="mt-12">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-16 w-full mt-4" />
<Skeleton className="h-16 w-3/4 mt-2" />
<Skeleton className="aspect-video w-full mt-12" />
<Skeleton className="h-4 w-full mt-12" />
<Skeleton className="h-4 w-full mt-2" />
<Skeleton className="h-4 w-2/3 mt-2" />
</div>
)}
{!loading && post && (
<article className="mt-12">
<PostMeta date={post.created_at} />
<h1 className="text-5xl md:text-7xl font-bold tracking-tighter mt-4 leading-tight">
{post.title}
</h1>
{post.cover_image && (
<div className="aspect-video overflow-hidden mt-12 bg-neutral-100">
<Image
src={post.cover_image}
alt={post.title}
width={1200}
height={675}
className="w-full h-full object-cover"
priority
/>
</div>
)}
<div className="mt-12 max-w-3xl">
<PostContent content={post.content} />
</div>
</article>
)}
</Container>
);
}