Files
blog/write-server/src/app/page.tsx
T
2026-06-22 00:52:51 +08:00

325 lines
14 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft, Search, Edit, Trash2, Plus, FileText, Eye,
FileText as PostIcon, Rss, Link2, GripVertical, X, CheckSquare, Square,
} from "lucide-react";
import Pagination from "@/components/Pagination";
interface PostItem {
slug: string;
title: string;
date: string;
draft: boolean;
categories: string[];
author: string;
}
export default function HomePage() {
const router = useRouter();
const [posts, setPosts] = useState<PostItem[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
// Stats
const [stats, setStats] = useState<{ posts: number; feeds: number; links: number } | null>(null);
// Batch
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchAction, setBatchAction] = useState<"categories" | "tags" | "draft" | null>(null);
const [batchValue, setBatchValue] = useState("");
const [batching, setBatching] = useState(false);
useEffect(() => {
fetch("/api/stats").then(r => r.json()).then(setStats).catch(() => {});
}, []);
const fetchPosts = useCallback(async () => {
setLoading(true);
try {
const p = Math.max(1, Math.floor(page));
const params = new URLSearchParams({ page: p.toString(), search: search || "", perPage: "20" });
const res = await fetch(`/api/posts?${params}`);
if (!res.ok) throw new Error("加载失败");
const data = await res.json();
setPosts(data.posts || []);
setTotal(data.total || 0);
setTotalPages(Math.max(1, data.totalPages || 1));
setTotalCount(data.totalCount || 0);
} catch {
setPosts([]); setTotal(0); setTotalPages(1);
} finally { setLoading(false); }
}, [page, search]);
useEffect(() => { fetchPosts(); }, [fetchPosts]);
const handleDelete = async (slug: string, title: string) => {
if (!confirm(`确定删除「${title}」吗?`)) return;
try {
const res = await fetch(`/api/posts/${slug}`, { method: "DELETE" });
const data = await res.json();
if (!res.ok) {
alert("删除失败:" + (data.error || "未知错误"));
return;
}
fetchPosts();
} catch (e) {
alert("删除失败:" + (e instanceof Error ? e.message : String(e)));
}
};
const handlePreview = async (slug: string) => {
await fetch("/api/hugo");
window.open(`/preview/${slug}.html`, "_blank");
};
const toggleSelect = (slug: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(slug) ? next.delete(slug) : next.add(slug);
return next;
});
};
const toggleAll = () => {
if (selected.size === posts.length) setSelected(new Set());
else setSelected(new Set(posts.map((p) => p.slug)));
};
const handleBatchApply = async () => {
if (selected.size === 0 || !batchAction) return;
setBatching(true);
try {
const body: Record<string, unknown> = {
slugs: Array.from(selected),
action: batchAction,
};
if (batchAction === "draft") {
body.draft = batchValue === "true";
} else if (batchAction === "categories") {
body.categories = batchValue.split("、").map((s) => s.trim()).filter(Boolean);
} else if (batchAction === "tags") {
body.tags = batchValue.split("、").map((s) => s.trim()).filter(Boolean);
}
const res = await fetch("/api/posts/batch", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("批量操作失败");
setSelected(new Set());
setBatchAction(null);
setBatchValue("");
setSelectMode(false);
fetchPosts();
} catch {} finally { setBatching(false); }
};
const formatDate = (d: string) => {
if (!d) return "-";
return new Date(d).toLocaleDateString("zh-CN");
};
return (
<div className="px-4 py-6 md:px-8 md:py-8">
{/* Dashboard stats: hidden on mobile, full cards on >=sm */}
{stats && (
<div className="hidden sm:grid sm:grid-cols-3 gap-4 mb-8">
<div className="bg-card border border-line rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-accent/8 rounded-lg flex items-center justify-center">
<PostIcon size={17} className="text-accent" />
</div>
<div>
<div className="text-[11px] text-muted">文章总数</div>
<div className="text-lg font-bold text-text">{stats.posts}</div>
</div>
</div>
<div className="bg-card border border-line rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-success/10 rounded-lg flex items-center justify-center">
<Rss size={17} className="text-success" />
</div>
<div>
<div className="text-[11px] text-muted">订阅源</div>
<div className="text-lg font-bold text-text">{stats.feeds}</div>
</div>
</div>
<div className="bg-card border border-line rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-[#c09a46]/10 rounded-lg flex items-center justify-center">
<Link2 size={17} className="text-[#c09a46]" />
</div>
<div>
<div className="text-[11px] text-muted">友链</div>
<div className="text-lg font-bold text-text">{stats.links}</div>
</div>
</div>
</div>
)}
{/* Header */}
<div className="hidden md:flex items-center justify-between mb-6 gap-3 flex-wrap">
<div>
<div className="flex items-center gap-3 mb-1">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">文章管理</h2>
</div>
<p className="text-[13px] text-muted mt-0.5">共 {totalCount} 篇文章</p>
</div>
<div className="flex items-center gap-2">
{/* Batch toggle */}
<button
type="button"
onClick={() => { setSelectMode(!selectMode); setSelected(new Set()); setBatchAction(null); }}
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] rounded-md border transition-colors ${
selectMode ? "bg-accent/5 border-accent/30 text-accent" : "border-line text-muted hover:text-accent"
}`}
>
<CheckSquare size={14} />
{selectMode ? "取消" : "批量"}
</button>
<Link href="/edit" className="inline-flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors">
<Plus size={15} /> 新建
</Link>
</div>
</div>
{/* Batch action bar */}
{selectMode && selected.size > 0 && (
<div className="mb-4 p-3 bg-accent/5 border border-accent/20 rounded-lg flex items-center gap-3 flex-wrap">
<span className="text-[12px] font-medium text-text">已选 {selected.size} 篇</span>
<select
value={batchAction || ""}
onChange={(e) => { setBatchAction(e.target.value as typeof batchAction || null); setBatchValue(""); }}
className="bg-card border border-line rounded-md px-2 py-1.5 text-[12px] focus:outline-none focus:border-accent/30"
>
<option value="">选择操作...</option>
<option value="categories">修改分类</option>
<option value="tags">修改标签</option>
<option value="draft">设为草稿/发布</option>
</select>
{batchAction === "draft" ? (
<select value={batchValue} onChange={(e) => setBatchValue(e.target.value)}
className="bg-card border border-line rounded-md px-2 py-1.5 text-[12px] focus:outline-none">
<option value="">选择状态...</option>
<option value="true">设为草稿</option>
<option value="false">设为发布</option>
</select>
) : batchAction ? (
<input type="text" value={batchValue} onChange={(e) => setBatchValue(e.target.value)}
className="flex-1 bg-card border border-line rounded-md px-2 py-1.5 text-[12px] focus:outline-none focus:border-accent/30"
placeholder={batchAction === "tags" ? "用顿号分隔" : "用顿号分隔"} />
) : null}
{batchAction && batchValue && (
<button type="button" onClick={handleBatchApply} disabled={batching}
className="px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50">
{batching ? "处理中..." : `应用到 ${selected.size} 篇`}
</button>
)}
<button type="button" onClick={() => setSelected(new Set())} className="p-1 rounded text-muted hover:text-text"><X size={14} /></button>
</div>
)}
{/* Search + (mobile) quick actions */}
<div className="flex items-center gap-2 mb-6">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted/50" />
<input type="text" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="搜索标题、分类..." className="w-full pl-9 pr-4 py-2.5 bg-card border border-line rounded-md text-[13px] text-text placeholder:text-muted/40 focus:outline-none focus:border-accent/30 transition-colors" />
</div>
<button
type="button"
onClick={() => { setSelectMode(!selectMode); setSelected(new Set()); setBatchAction(null); }}
aria-label="批量"
className={`md:hidden shrink-0 h-[38px] w-[38px] inline-flex items-center justify-center rounded-md border transition-colors ${
selectMode ? "bg-accent/5 border-accent/30 text-accent" : "border-line text-muted hover:text-accent"
}`}
>
<CheckSquare size={16} />
</button>
<Link
href="/edit"
aria-label="新建"
className="md:hidden shrink-0 h-[38px] w-[38px] inline-flex items-center justify-center rounded-md bg-accent text-white hover:bg-accent-hover transition-colors"
>
<Plus size={17} />
</Link>
</div>
{/* Article list */}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : posts.length === 0 ? (
<div className="text-center py-20">
<FileText size={28} className="mx-auto text-muted/25 mb-3" />
<p className="text-sm text-muted">{search ? "没有匹配的文章" : "暂无文章"}</p>
</div>
) : (
<div className="space-y-2">
{/* Select all checkbox */}
{selectMode && (
<div className="flex items-center gap-2 px-4 py-2">
<button type="button" onClick={toggleAll} className="text-[12px] text-muted hover:text-accent">
{selected.size === posts.length ? "取消全选" : "全选"}
</button>
</div>
)}
{posts.map((post, i) => (
<div key={`${post.slug}-${i}`}
className="group flex items-center gap-4 px-5 py-3.5 bg-card border border-line rounded-md hover:border-accent/15 transition-all">
{/* Select checkbox */}
{selectMode && (
<button type="button" onClick={() => toggleSelect(post.slug)} className="shrink-0">
{selected.has(post.slug)
? <CheckSquare size={16} className="text-accent" />
: <Square size={16} className="text-muted/40" />}
</button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<button type="button" onClick={() => router.push(`/edit/${post.slug}`)}
className="font-medium text-[14px] text-text truncate hover:text-accent transition-colors text-left">
{post.title}
</button>
{post.draft ? (
<span className="shrink-0 px-1.5 py-px text-[10px] rounded bg-[#fef3c7] text-[#92400e] font-medium">草稿</span>
) : (
<span className="shrink-0 px-1.5 py-px text-[10px] rounded bg-[#d1fae5] text-[#065f46] font-medium">已发布</span>
)}
</div>
<div className="flex items-center gap-2 text-[12px] text-muted">
<span>{formatDate(post.date)}</span>
{post.categories.length > 0 && <><span className="text-line">|</span><span>{post.categories.join("、")}</span></>}
</div>
</div>
{!selectMode && (
<div className="flex items-center gap-0.5 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button type="button" onClick={() => handlePreview(post.slug)}
className="p-1.5 rounded hover:bg-accent/8 text-muted hover:text-accent transition-colors" title="预览"><Eye size={14} /></button>
<button type="button" onClick={() => router.push(`/edit/${post.slug}`)}
className="p-1.5 rounded hover:bg-accent/8 text-muted hover:text-accent transition-colors" title="编辑"><Edit size={14} /></button>
<button type="button" onClick={() => handleDelete(post.slug, post.title)}
className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors" title="删除"><Trash2 size={14} /></button>
</div>
)}
</div>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
{total > 0 && <p className="text-center text-xs text-muted mt-2">第 {page} 页 / 共 {totalPages} 页({total} 篇)</p>}
</div>
);
}