feat: 重构博客为水墨纸质风格 + 搭建后台管理系统

- 重新设计全站 UI:parchment/ink/terracotta 水墨纸质色系,宋式 serif 排版
- 新增页面:文章列表、文章详情、分类、标签、关于
- GSAP ScrollTrigger 滚动动画 + 逐字揭示效果
- 后台管理系统 /admin:文章/分类/标签 CRUD,JSON 文件存储
- 登录认证(cookie session)
- 设计系统文档 UI.md
This commit is contained in:
胡旭
2026-06-24 08:45:28 +08:00
parent 507f12e501
commit dce8fe62ea
35 changed files with 3124 additions and 96 deletions
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useState, useEffect } from "react";
import type { Category } from "@/lib/store";
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
async function load() {
const res = await fetch("/api/categories");
setCategories(await res.json());
setLoading(false);
}
useEffect(() => { load(); }, []);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
await fetch("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
});
setNewName("");
setNewDesc("");
load();
}
async function handleDelete(id: string, name: string) {
if (!confirm(`确定删除分类「${name}」?`)) return;
await fetch(`/api/categories?id=${id}`, { method: "DELETE" });
load();
}
async function handleSave(id: string) {
await fetch(`/api/categories?id=${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName }),
});
setEditingId(null);
load();
}
if (loading) return <div className="font-sans text-ink-muted">...</div>;
return (
<div>
<h1 className="font-display text-3xl font-medium text-ink mb-8"></h1>
{/* Add form */}
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="分类名称"
className="flex-1 max-w-xs px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
/>
<input
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="描述(可选)"
className="flex-1 max-w-sm px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
/>
<button type="submit" className="px-5 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors shrink-0">
</button>
</form>
{/* List */}
<div className="space-y-2">
{categories.map((cat) => (
<div key={cat.id} className="flex items-center justify-between p-4 rounded-xl bg-cream border border-warm-gray/10">
{editingId === cat.id ? (
<div className="flex items-center gap-2 flex-1">
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-warm-gray/20 bg-white font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40"
autoFocus
/>
<button onClick={() => handleSave(cat.id)} className="font-sans text-xs text-sage hover:text-ink transition-colors"></button>
<button onClick={() => setEditingId(null)} className="font-sans text-xs text-ink-muted hover:text-ink transition-colors"></button>
</div>
) : (
<>
<div>
<span className="font-display text-base text-ink">{cat.name}</span>
{cat.description && <span className="ml-3 font-sans text-sm text-ink-muted">{cat.description}</span>}
</div>
<div className="flex items-center gap-3">
<button
onClick={() => { setEditingId(cat.id); setEditName(cat.name); }}
className="font-sans text-xs text-ink-muted hover:text-terracotta transition-colors"
></button>
<button onClick={() => handleDelete(cat.id, cat.name)} className="font-sans text-xs text-ink-muted hover:text-red-600 transition-colors"></button>
</div>
</>
)}
</div>
))}
{categories.length === 0 && (
<div className="text-center py-16 font-sans text-ink-muted"></div>
)}
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
const navItems = [
{ label: "仪表盘", href: "/admin", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4" },
{ label: "文章", href: "/admin/posts", icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2" },
{ label: "分类", href: "/admin/categories", icon: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" },
{ label: "标签", href: "/admin/tags", icon: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [authed, setAuthed] = useState<boolean | null>(null);
// Login page — render bare children without sidebar or auth check
const isLoginPage = pathname === "/admin/login";
useEffect(() => {
if (isLoginPage) return;
fetch("/api/auth")
.then((r) => r.json())
.then((data) => {
if (!data.authenticated) router.push("/admin/login");
else setAuthed(true);
});
}, [router, isLoginPage]);
if (isLoginPage) {
return <>{children}</>;
}
if (authed === null || !authed) {
return <div className="min-h-screen bg-parchment flex items-center justify-center font-sans text-ink-muted">...</div>;
}
async function handleLogout() {
await fetch("/api/auth", { method: "DELETE" });
router.push("/admin/login");
}
return (
<div className="min-h-screen bg-parchment flex">
{/* Sidebar */}
<aside className="w-56 shrink-0 border-r border-warm-gray/15 bg-cream/50 flex flex-col">
<div className="p-5 border-b border-warm-gray/10">
<Link href="/admin" className="font-display text-lg font-medium text-ink hover:text-terracotta transition-colors">
· Admin
</Link>
</div>
<nav className="flex-1 p-3 space-y-1">
{navItems.map((item) => {
const isActive = item.href === "/admin"
? pathname === "/admin"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm transition-colors ${
isActive ? "bg-terracotta/10 text-terracotta" : "text-ink-muted hover:text-ink hover:bg-warm-gray/10"
}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d={item.icon} />
</svg>
{item.label}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-warm-gray/10 space-y-1">
<Link href="/" className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-ink-muted hover:text-ink hover:bg-warm-gray/10 transition-colors">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
<button onClick={handleLogout} className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-ink-muted hover:text-red-600 hover:bg-red-50 transition-colors text-left">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
退
</button>
</div>
</aside>
{/* Main */}
<div className="flex-1 overflow-auto">
<div className="p-8 max-w-5xl">
{children}
</div>
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
router.push("/admin");
} else {
const data = await res.json();
setError(data.error || "登录失败");
}
setLoading(false);
}
return (
<div className="min-h-screen bg-parchment flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-10">
<h1 className="font-display text-3xl font-medium text-ink"></h1>
<p className="mt-2 font-sans text-sm text-ink-muted">asui.xyz</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<input
type="password"
placeholder="输入管理密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
autoFocus
/>
</div>
{error && <p className="font-sans text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl bg-ink text-cream font-sans text-sm tracking-wide hover:bg-terracotta transition-colors disabled:opacity-50"
>
{loading ? "验证中..." : "登录"}
</button>
</form>
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import type { Post, Category, Tag } from "@/lib/store";
export default function DashboardPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
fetch("/api/posts").then((r) => r.json()),
fetch("/api/categories").then((r) => r.json()),
fetch("/api/tags").then((r) => r.json()),
]).then(([p, c, t]) => {
setPosts(p);
setCategories(c);
setTags(t);
setLoading(false);
});
}, []);
if (loading) {
return <div className="font-sans text-ink-muted">...</div>;
}
const published = posts.filter((p) => p.status === "published").length;
const drafts = posts.filter((p) => p.status === "draft").length;
const featured = posts.filter((p) => p.featured).length;
const stats = [
{ label: "文章总数", value: posts.length, color: "text-ink" },
{ label: "已发布", value: published, color: "text-sage" },
{ label: "草稿", value: drafts, color: "text-terracotta" },
{ label: "精选", value: featured, color: "text-terracotta" },
{ label: "分类", value: categories.length, color: "text-ink" },
{ label: "标签", value: tags.length, color: "text-ink" },
];
return (
<div>
<div className="flex items-center justify-between mb-8">
<h1 className="font-display text-3xl font-medium text-ink"></h1>
<Link href="/admin/posts/new" className="px-4 py-2 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">
+
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-10">
{stats.map((s) => (
<div key={s.label} className="p-4 rounded-xl bg-cream border border-warm-gray/10">
<div className={`font-display text-2xl font-medium ${s.color}`}>{s.value}</div>
<div className="font-sans text-xs text-ink-muted mt-1">{s.label}</div>
</div>
))}
</div>
{/* Recent posts */}
<h2 className="font-display text-xl font-medium text-ink mb-4"></h2>
<div className="space-y-2">
{posts.slice(0, 5).map((post) => (
<Link
key={post.id}
href={`/admin/posts/${post.id}`}
className="flex items-center justify-between p-4 rounded-xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="font-display text-base text-ink truncate">{post.title}</div>
<div className="font-sans text-xs text-ink-muted mt-0.5">{post.category} · {post.date}</div>
</div>
<span className={`ml-4 shrink-0 font-sans text-xs px-2 py-0.5 rounded-full ${
post.status === "published" ? "bg-sage/10 text-sage" : "bg-terracotta/10 text-terracotta"
}`}>
{post.status === "published" ? "已发布" : "草稿"}
</span>
</Link>
))}
</div>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import type { Post, Category, Tag } from "@/lib/store";
export default function EditPostPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [post, setPost] = useState<Post | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({
title: "", slug: "", excerpt: "", content: "",
category: "", tags: [] as string[], readingTime: 5,
featured: false, status: "draft" as "draft" | "published",
date: "",
});
useEffect(() => {
Promise.all([
fetch(`/api/posts/${id}`).then((r) => r.json()),
fetch("/api/categories").then((r) => r.json()),
fetch("/api/tags").then((r) => r.json()),
]).then(([p, c, t]) => {
setPost(p);
setCategories(c);
setAllTags(t);
setForm({
title: p.title, slug: p.slug, excerpt: p.excerpt,
content: p.content, category: p.category, tags: p.tags,
readingTime: p.readingTime, featured: p.featured,
status: p.status, date: p.date,
});
setLoading(false);
});
}, [id]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch(`/api/posts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok) router.push("/admin/posts");
}
function toggleTag(tagName: string) {
setForm((prev) => ({
...prev,
tags: prev.tags.includes(tagName)
? prev.tags.filter((t) => t !== tagName)
: [...prev.tags, tagName],
}));
}
if (loading) return <div className="font-sans text-ink-muted">...</div>;
if (!post) return <div className="font-sans text-red-600"></div>;
return (
<div>
<div className="flex items-center gap-4 mb-8">
<button onClick={() => router.back()} className="font-sans text-sm text-ink-muted hover:text-ink transition-colors"> </button>
<h1 className="font-display text-3xl font-medium text-ink"></h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-display text-lg text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">Slug</label>
<input
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<textarea
value={form.excerpt}
onChange={(e) => setForm({ ...form, excerpt: e.target.value })}
rows={2}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors resize-none"
/>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">HTML</label>
<textarea
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
rows={12}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors resize-y font-mono"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<select
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
required
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="number" min={1}
value={form.readingTime}
onChange={(e) => setForm({ ...form, readingTime: Number(e.target.value) })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => (
<button key={tag.id} type="button" onClick={() => toggleTag(tag.name)}
className={`font-sans text-xs px-3 py-1.5 rounded-full border transition-colors ${
form.tags.includes(tag.name)
? "bg-terracotta/10 border-terracotta/30 text-terracotta"
: "border-warm-gray/20 text-ink-muted hover:border-terracotta/20"
}`}
>{tag.name}</button>
))}
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input type="checkbox" checked={form.featured} onChange={(e) => setForm({ ...form, featured: e.target.checked })} className="accent-terracotta" />
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input type="radio" name="status" checked={form.status === "draft"} onChange={() => setForm({ ...form, status: "draft" })} className="accent-terracotta" />
稿
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input type="radio" name="status" checked={form.status === "published"} onChange={() => setForm({ ...form, status: "published" })} className="accent-terracotta" />
</label>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="px-6 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors"></button>
<button type="button" onClick={() => router.back()} className="px-6 py-2.5 rounded-lg border border-warm-gray/20 font-sans text-sm text-ink-muted hover:text-ink transition-colors"></button>
</div>
</form>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Category, Tag } from "@/lib/store";
export default function NewPostPage() {
const router = useRouter();
const [categories, setCategories] = useState<Category[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
const [form, setForm] = useState({
title: "",
slug: "",
excerpt: "",
content: "",
category: "",
tags: [] as string[],
readingTime: 5,
featured: false,
status: "draft" as "draft" | "published",
date: new Date().toISOString().slice(0, 10),
});
useEffect(() => {
fetch("/api/categories").then((r) => r.json()).then(setCategories);
fetch("/api/tags").then((r) => r.json()).then(setAllTags);
}, []);
function autoSlug(title: string) {
return title
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff]+/g, "-")
.replace(/^-|-$/g, "");
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const slug = form.slug || autoSlug(form.title);
const res = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, slug }),
});
if (res.ok) router.push("/admin/posts");
}
function toggleTag(tagName: string) {
setForm((prev) => ({
...prev,
tags: prev.tags.includes(tagName)
? prev.tags.filter((t) => t !== tagName)
: [...prev.tags, tagName],
}));
}
return (
<div>
<div className="flex items-center gap-4 mb-8">
<button onClick={() => router.back()} className="font-sans text-sm text-ink-muted hover:text-ink transition-colors">
</button>
<h1 className="font-display text-3xl font-medium text-ink"></h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-display text-lg text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
placeholder="文章标题"
required
/>
</div>
{/* Slug + Date */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">Slug</label>
<input
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
placeholder="my-post-slug"
/>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
{/* Excerpt */}
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<textarea
value={form.excerpt}
onChange={(e) => setForm({ ...form, excerpt: e.target.value })}
rows={2}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors resize-none"
placeholder="文章摘要..."
/>
</div>
{/* Content */}
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">HTML</label>
<textarea
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
rows={12}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors resize-y font-mono"
placeholder="<p>文章内容...</p>"
required
/>
</div>
{/* Category + Reading Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<select
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
required
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="number"
min={1}
value={form.readingTime}
onChange={(e) => setForm({ ...form, readingTime: Number(e.target.value) })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
{/* Tags */}
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.name)}
className={`font-sans text-xs px-3 py-1.5 rounded-full border transition-colors ${
form.tags.includes(tag.name)
? "bg-terracotta/10 border-terracotta/30 text-terracotta"
: "border-warm-gray/20 text-ink-muted hover:border-terracotta/20"
}`}
>
{tag.name}
</button>
))}
</div>
</div>
{/* Status + Featured */}
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input
type="checkbox"
checked={form.featured}
onChange={(e) => setForm({ ...form, featured: e.target.checked })}
className="accent-terracotta"
/>
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input
type="radio"
name="status"
checked={form.status === "draft"}
onChange={() => setForm({ ...form, status: "draft" })}
className="accent-terracotta"
/>
稿
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input
type="radio"
name="status"
checked={form.status === "published"}
onChange={() => setForm({ ...form, status: "published" })}
className="accent-terracotta"
/>
</label>
</div>
{/* Submit */}
<div className="flex gap-3 pt-4">
<button type="submit" className="px-6 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">
</button>
<button type="button" onClick={() => router.back()} className="px-6 py-2.5 rounded-lg border border-warm-gray/20 font-sans text-sm text-ink-muted hover:text-ink transition-colors">
</button>
</div>
</form>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import type { Post } from "@/lib/store";
export default function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
async function loadPosts() {
const res = await fetch("/api/posts");
const data = await res.json();
setPosts(data);
setLoading(false);
}
useEffect(() => { loadPosts(); }, []);
async function handleDelete(id: string, title: string) {
if (!confirm(`确定删除「${title}」?`)) return;
await fetch(`/api/posts?id=${id}`, { method: "DELETE" });
loadPosts();
}
const filtered = filter === "all" ? posts : posts.filter((p) => p.status === filter);
if (loading) return <div className="font-sans text-ink-muted">...</div>;
return (
<div>
<div className="flex items-center justify-between mb-8">
<h1 className="font-display text-3xl font-medium text-ink"></h1>
<Link href="/admin/posts/new" className="px-4 py-2 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">
+
</Link>
</div>
{/* Filter tabs */}
<div className="flex gap-4 mb-6 font-sans text-sm">
{(["all", "published", "draft"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`pb-1 border-b-2 transition-colors ${
filter === f ? "border-terracotta text-ink" : "border-transparent text-ink-muted hover:text-ink"
}`}
>
{f === "all" ? "全部" : f === "published" ? "已发布" : "草稿"}
<span className="ml-1 text-xs text-ink-muted">({f === "all" ? posts.length : posts.filter((p) => p.status === f).length})</span>
</button>
))}
</div>
{/* Posts list */}
<div className="space-y-3">
{filtered.map((post) => (
<div key={post.id} className="p-5 rounded-xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<Link href={`/admin/posts/${post.id}`} className="font-display text-lg text-ink hover:text-terracotta transition-colors">
{post.title}
</Link>
<p className="font-sans text-sm text-ink-muted mt-1 line-clamp-1">{post.excerpt}</p>
<div className="flex items-center gap-3 mt-2 font-sans text-xs text-ink-muted">
<span>{post.category}</span>
<span>·</span>
<span>{post.date}</span>
<span>·</span>
<span>{post.readingTime} </span>
{post.featured && <span className="text-terracotta"></span>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`font-sans text-xs px-2 py-0.5 rounded-full ${
post.status === "published" ? "bg-sage/10 text-sage" : "bg-terracotta/10 text-terracotta"
}`}>
{post.status === "published" ? "已发布" : "草稿"}
</span>
<Link href={`/admin/posts/${post.id}`} className="font-sans text-xs text-ink-muted hover:text-terracotta transition-colors px-2">
</Link>
<button onClick={() => handleDelete(post.id, post.title)} className="font-sans text-xs text-ink-muted hover:text-red-600 transition-colors px-2">
</button>
</div>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-center py-16 font-sans text-ink-muted"></div>
)}
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useState, useEffect } from "react";
import type { Tag } from "@/lib/store";
export default function TagsPage() {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
async function load() {
const res = await fetch("/api/tags");
setTags(await res.json());
setLoading(false);
}
useEffect(() => { load(); }, []);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
await fetch("/api/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
setNewName("");
load();
}
async function handleDelete(id: string, name: string) {
if (!confirm(`确定删除标签「${name}」?`)) return;
await fetch(`/api/tags?id=${id}`, { method: "DELETE" });
load();
}
if (loading) return <div className="font-sans text-ink-muted">...</div>;
return (
<div>
<h1 className="font-display text-3xl font-medium text-ink mb-8"></h1>
{/* Add form */}
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="标签名称"
className="flex-1 max-w-xs px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
/>
<button type="submit" className="px-5 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors shrink-0">
</button>
</form>
{/* Tag cloud */}
<div className="p-6 rounded-xl bg-cream border border-warm-gray/10">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span key={tag.id} className="group inline-flex items-center gap-1.5 font-sans text-sm px-3 py-1.5 rounded-full border border-warm-gray/15 text-ink hover:border-terracotta/30 transition-colors">
{tag.name}
<button
onClick={() => handleDelete(tag.id, tag.name)}
className="opacity-0 group-hover:opacity-100 text-ink-muted hover:text-red-600 transition-all text-xs leading-none"
>×</button>
</span>
))}
</div>
{tags.length === 0 && (
<div className="text-center py-8 font-sans text-ink-muted"></div>
)}
</div>
</div>
);
}