`)
+ .replace(//g, ``)
+ .replace(//g, ``)
+ .replace(/
/g, `
`)
+ .replace(/
/g, `
`)
+ .replace(//g, ``)
+ .replace(//g, ``)
+ .replace(/- /g, `
- `)
+ .replace(//g, `
`)
+ .replace(/| /g, ` | `)
+ .replace(/ | /g, ` | `);
+
+ html = `
+ — END — `;
+
+ return { html, imageUrls };
+}
+
+export async function GET() {
+ try {
+ const status = await getWechatStatus();
+ return NextResponse.json(status);
+ } catch (error: unknown) {
+ const msg = error instanceof Error ? error.message : "Unknown error";
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { articles } = body;
+ if (!articles || !Array.isArray(articles) || articles.length === 0) {
+ return NextResponse.json({ error: "articles is required" }, { status: 400 });
+ }
+
+ const processedArticles = articles.map((a: Record) => {
+ const slug = (a.slug as string) || "";
+ const rawContent = (a.content as string) || "";
+ const { html, imageUrls } = mdToWechatHtml(rawContent, slug);
+
+ const result: Record = {
+ title: a.title,
+ author: a.author,
+ content: html,
+ content_source_url: a.content_source_url,
+ thumb_media_url: a.thumb_media_url,
+ show_cover_pic: a.show_cover_pic ?? 1,
+ need_open_comment: a.need_open_comment ?? 0,
+ };
+
+ if (imageUrls.length > 0) {
+ result.content_image_urls = imageUrls.map((u) => ({ original_url: u }));
+ }
+
+ return result;
+ });
+
+ const result = await syncWechat(processedArticles);
+ return NextResponse.json(result);
+ } catch (error: unknown) {
+ const msg = error instanceof Error ? error.message : "Unknown error";
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
diff --git a/write/src/app/api/stats/route.ts b/write/src/app/api/stats/route.ts
new file mode 100644
index 00000000..434e4d6a
--- /dev/null
+++ b/write/src/app/api/stats/route.ts
@@ -0,0 +1,30 @@
+import { NextResponse } from "next/server";
+import { getPostCount } from "@/lib/posts";
+import { getLinks } from "@/lib/rssapi";
+
+export async function GET() {
+ try {
+ const postCount = getPostCount();
+
+ // Try getting RSS stats (non-blocking)
+ let feedCount = 0;
+ let linkCount = 0;
+ try {
+ const links = await getLinks(true);
+ linkCount = links.length;
+ } catch {}
+ try {
+ const { getFeeds } = await import("@/lib/rssapi");
+ const feeds = await getFeeds();
+ feedCount = feeds.length;
+ } catch {}
+
+ return NextResponse.json({
+ posts: postCount,
+ feeds: feedCount,
+ links: linkCount,
+ });
+ } catch {
+ return NextResponse.json({ posts: 0, feeds: 0, links: 0 });
+ }
+}
diff --git a/write/src/app/api/upload/route.ts b/write/src/app/api/upload/route.ts
new file mode 100644
index 00000000..fc9a52f3
--- /dev/null
+++ b/write/src/app/api/upload/route.ts
@@ -0,0 +1,53 @@
+import { NextRequest, NextResponse } from "next/server";
+import fs from "fs";
+import path from "path";
+
+export async function POST(request: NextRequest) {
+ try {
+ const formData = await request.formData();
+ const file = formData.get("file") as File | null;
+ const dirPath = formData.get("dirPath") as string | null;
+
+ if (!file) {
+ return NextResponse.json({ error: "No file provided" }, { status: 400 });
+ }
+ if (!file.type.startsWith("image/")) {
+ return NextResponse.json({ error: "Only images allowed" }, { status: 400 });
+ }
+
+ const bytes = await file.arrayBuffer();
+ const buffer = Buffer.from(bytes);
+
+ // Determine upload directory
+ let uploadDir: string;
+ let isArticleFolder = false;
+ if (dirPath && fs.existsSync(dirPath)) {
+ uploadDir = dirPath;
+ isArticleFolder = true;
+ } else {
+ // Fallback to static/image
+ const year = new Date().getFullYear().toString();
+ uploadDir = path.join(process.cwd(), "..", "static", "image", year);
+ }
+
+ if (!fs.existsSync(uploadDir)) {
+ fs.mkdirSync(uploadDir, { recursive: true });
+ }
+
+ // Generate unique filename
+ const ext = file.name.split(".").pop() || "png";
+ const baseName = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+ const fileName = `${baseName}.${ext}`;
+ const filePath = path.join(uploadDir, fileName);
+ fs.writeFileSync(filePath, buffer);
+
+ // Article folder: bare filename. Static fallback: absolute from Hugo root
+ const url = isArticleFolder ? fileName : `/image/${new Date().getFullYear()}/${fileName}`;
+ return NextResponse.json({ url, fileName });
+ } catch (error: unknown) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : "Upload failed" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/write/src/app/comments/page.tsx b/write/src/app/comments/page.tsx
new file mode 100644
index 00000000..5fd4bc06
--- /dev/null
+++ b/write/src/app/comments/page.tsx
@@ -0,0 +1,465 @@
+"use client";
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import {
+ MessageSquare, Eye, EyeOff, Trash2, Loader2, LogOut, Key,
+ CornerDownRight, Send
+} from "lucide-react";
+
+interface ArtalkPage {
+ id: number; key: string; title: string; url: string;
+ comment_count?: number; date: string;
+}
+interface ArtalkComment {
+ id: number; content: string; nick: string; email: string;
+ page_key: string; page_title: string; is_collapsed: boolean;
+ is_pending: boolean; rid: number; date: string; ua: string;
+}
+interface UserInfo { name: string; email: string; }
+
+const CACHE_KEY = "artalk_admin_cred";
+const TOKEN_KEY = "artalk_admin_token";
+const USER_KEY = "artalk_admin_user";
+
+function lsGet(k: string) { try { return localStorage.getItem(k); } catch { return null; } }
+function lsSet(k: string, v: string) { try { localStorage.setItem(k, v); } catch {} }
+function lsDel(k: string) { try { localStorage.removeItem(k); } catch {} }
+
+export default function CommentsPage() {
+ const [loggedIn, setLoggedIn] = useState(false);
+ const [loginForm, setLoginForm] = useState({ email: "", password: "" });
+ const [loginError, setLoginError] = useState("");
+ const [loggingIn, setLoggingIn] = useState(false);
+ const [remember, setRemember] = useState(true);
+
+ const [pages, setPages] = useState([]);
+ const [selectedPage, setSelectedPage] = useState(null);
+ const [comments, setComments] = useState([]);
+ const [loadingPages, setLoadingPages] = useState(false);
+ const [loadingComments, setLoadingComments] = useState(false);
+ const [pagesTotal, setPagesTotal] = useState(0);
+ const [error, setError] = useState(null);
+ const [token, setToken] = useState(() => lsGet(TOKEN_KEY));
+ const [user, setUser] = useState(() => {
+ const raw = lsGet(USER_KEY);
+ return raw ? JSON.parse(raw) : null;
+ });
+
+ // Reply state
+ const [replyingTo, setReplyingTo] = useState(null);
+ const [replyContent, setReplyContent] = useState("");
+ const [sendingReply, setSendingReply] = useState(false);
+ const replyRef = useRef(null);
+
+ // Auto-login
+ useEffect(() => {
+ const cachedToken = lsGet(TOKEN_KEY);
+ const cachedUser = lsGet(USER_KEY);
+ if (cachedToken && cachedUser) {
+ setToken(cachedToken);
+ setUser(JSON.parse(cachedUser));
+ setLoggedIn(true);
+ return;
+ }
+ const cachedCred = lsGet(CACHE_KEY);
+ if (cachedCred) {
+ try {
+ const cred = JSON.parse(cachedCred);
+ setLoginForm(cred);
+ doLogin(cred);
+ } catch {}
+ }
+ }, []);
+
+ const doLogin = async (cred: { email: string; password: string }, showError = true) => {
+ setLoggingIn(true);
+ setLoginError("");
+ try {
+ const res = await fetch("/api/comments/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(cred),
+ });
+ if (!res.ok) { const d = await res.json(); throw new Error(d.error || "登录失败"); }
+ const d = await res.json();
+ const t = d.token;
+ const u = d.user;
+ if (remember) {
+ lsSet(CACHE_KEY, JSON.stringify(cred));
+ lsSet(TOKEN_KEY, t);
+ lsSet(USER_KEY, JSON.stringify({ name: u.name, email: u.email }));
+ }
+ setToken(t);
+ setUser({ name: u.name, email: u.email });
+ setLoggedIn(true);
+ } catch (err: unknown) {
+ if (showError) setLoginError(err instanceof Error ? err.message : "登录失败");
+ else { lsDel(CACHE_KEY); lsDel(TOKEN_KEY); lsDel(USER_KEY); }
+ } finally { setLoggingIn(false); }
+ };
+
+ const handleLogin = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!loginForm.email || !loginForm.password) { setLoginError("请填写邮箱和密码"); return; }
+ doLogin(loginForm);
+ };
+
+ const handleLogout = () => {
+ lsDel(CACHE_KEY); lsDel(TOKEN_KEY); lsDel(USER_KEY);
+ setToken(null); setUser(null); setLoggedIn(false);
+ setPages([]); setComments([]); setSelectedPage(null);
+ setLoginForm({ email: "", password: "" });
+ };
+
+ // Fetch pages + sort by latest comment activity
+ const fetchPages = useCallback(async () => {
+ setLoadingPages(true);
+ setError(null);
+ try {
+ // Fetch both Artalk pages and Hugo posts in parallel
+ const p = new URLSearchParams({ limit: "300" });
+ if (token) p.set("token", token);
+ const [artalkRes, postsRes] = await Promise.all([
+ fetch(`/api/comments?${p}`),
+ fetch("/api/posts?perPage=9999"),
+ ]);
+
+ if (!artalkRes.ok) {
+ if (artalkRes.status === 401) { setLoggedIn(false); lsDel(CACHE_KEY); lsDel(TOKEN_KEY); lsDel(USER_KEY); return; }
+ throw new Error("加载失败");
+ }
+
+ const artalkData = await artalkRes.json();
+ let allPages: ArtalkPage[] = artalkData.pages || [];
+ setPagesTotal(artalkData.total || 0);
+
+ // Match with Hugo posts to get MD file dates for sorting
+ if (postsRes.ok) {
+ const postsData = await postsRes.json();
+ const posts: { title: string; date: string }[] = postsData.posts || [];
+ // Build a map: title -> post date
+ const dateMap = new Map();
+ for (const post of posts) {
+ const t = post.title.trim();
+ dateMap.set(t, new Date(post.date).getTime());
+ }
+
+ // Sort Artalk pages by matched MD date (newest first)
+ allPages.sort((a, b) => {
+ const da = dateMap.get((a.title || "").trim());
+ const db = dateMap.get((b.title || "").trim());
+ if (da && db) return db - da;
+ if (da) return -1;
+ if (db) return 1;
+ return 0;
+ });
+ }
+
+ setPages(allPages);
+ if (!selectedPage && allPages.length > 0) {
+ setSelectedPage(allPages[0]);
+ }
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : "加载失败");
+ } finally { setLoadingPages(false); }
+ }, [token]);
+
+ useEffect(() => { if (loggedIn && token) fetchPages(); }, [fetchPages, loggedIn, token]);
+
+ // Load comments when page selected
+ useEffect(() => {
+ if (!selectedPage) return;
+ setLoadingComments(true);
+ setError(null);
+ (async () => {
+ try {
+ const p = new URLSearchParams({ page_key: selectedPage.key, limit: "200" });
+ if (token) p.set("token", token);
+ const res = await fetch(`/api/comments?${p}`);
+ if (!res.ok) throw new Error("加载失败");
+ const data = await res.json();
+ const seen = new Set();
+ const unique: ArtalkComment[] = [];
+ for (const c of data.comments || []) {
+ if (!seen.has(c.id)) { seen.add(c.id); unique.push(c); }
+ }
+ // Sort newest first
+ unique.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+ setComments(unique);
+ // Update comment count for this page
+ setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: unique.length } : p));
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : "加载失败");
+ } finally { setLoadingComments(false); }
+ })();
+ }, [selectedPage, token]);
+
+ const handleToggleVisibility = async (c: ArtalkComment) => {
+ try {
+ const body: Record = { is_collapsed: !c.is_collapsed };
+ if (token) body.token = token;
+ await fetch(`/api/comments/${c.id}`, {
+ method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
+ });
+ setComments((prev) => prev.map((x) => x.id === c.id ? { ...x, is_collapsed: !x.is_collapsed } : x));
+ } catch { setError("操作失败"); }
+ };
+
+ const handleDelete = async (c: ArtalkComment) => {
+ if (!confirm(`确定删除「${c.nick}」的评论?`)) return;
+ try {
+ const body: Record = {};
+ if (token) body.token = token;
+ await fetch(`/api/comments/${c.id}`, {
+ method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
+ });
+ setComments((prev) => prev.filter((x) => x.id !== c.id));
+ if (selectedPage) {
+ setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: Math.max(0, (p.comment_count ?? 0) - 1) } : p));
+ }
+ } catch { setError("删除失败"); }
+ };
+
+ const handleReply = async () => {
+ if (!replyContent.trim() || !selectedPage || !user) return;
+ setSendingReply(true);
+ try {
+ const body: Record = {
+ page_key: selectedPage.key,
+ content: replyContent,
+ name: user.name,
+ email: user.email,
+ };
+ if (replyingTo) body.rid = replyingTo;
+ if (token) body.token = token;
+
+ const res = await fetch("/api/comments", {
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error("回复失败");
+ const newComment = await res.json();
+
+ // Add new comment to list
+ setComments((prev) => [newComment, ...prev]);
+ if (selectedPage) {
+ setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: (p.comment_count ?? 0) + 1 } : p));
+ }
+ setReplyContent("");
+ setReplyingTo(null);
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : "回复失败");
+ } finally { setSendingReply(false); }
+ };
+
+ const startReply = (commentId: number) => {
+ setReplyingTo(commentId);
+ setReplyContent("");
+ setTimeout(() => replyRef.current?.focus(), 50);
+ };
+
+ const fmtDate = (d: string) => d ? new Date(d).toLocaleString("zh-CN") : "-";
+ const stripHtml = (h: string) => h.replace(/<[^>]*>/g, "").slice(0, 300);
+
+ // --- Login ---
+ if (!loggedIn) {
+ return (
+
+
+
+
+
+
+ 评论管理
+ Artalk 管理员登录
+
+
+
+
+ );
+ }
+
+ // --- Main ---
+ return (
+
+ {/* Top bar */}
+
+ 评论管理
+
+ {user && {user.name}}
+
+
+
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {/* Two columns */}
+
+ {/* Left: Articles */}
+
+
+ 文章列表 {pagesTotal > 0 && · {pagesTotal}}
+
+
+ {loadingPages ? (
+
+ 加载中
+
+ ) : (
+
+ {pages.map((p) => (
+
+ ))}
+
+ )}
+
+
+ {/* Right: Comments */}
+
+ {selectedPage ? (
+ <>
+
+ {selectedPage.title} · {comments.length} 条
+
+
+ {loadingComments ? (
+
+ 加载评论
+
+ ) : (
+
+ {comments.length === 0 && (
+
+ 暂无评论
+
+ )}
+ {comments.map((c) => (
+
+
+
+
+ {c.nick}
+ {c.rid > 0 && 回复}
+ {c.is_collapsed && 已隐藏}
+
+ {/* Parent comment preview */}
+ {c.rid > 0 && (() => {
+ const parent = comments.find((x) => x.id === c.rid);
+ return parent ? (
+
+ {parent.nick}
+ :
+ {stripHtml(parent.content).slice(0, 60)}
+ {(parent.content || "").replace(/<[^>]*>/g, "").length > 60 ? "..." : ""}
+
+ ) : null;
+ })()}
+ {stripHtml(c.content)}
+
+ {fmtDate(c.date)}
+ {c.ua?.includes("Mobile") ? "📱" : "💻"}
+
+
+
+ {/* Inline reply form */}
+ {replyingTo === c.id && (
+
+ )}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+ >
+ ) : (
+
+
+ 选择左侧文章查看评论
+
+ )}
+
+
+
+ );
+}
diff --git a/write/src/app/edit/[slug]/page.tsx b/write/src/app/edit/[slug]/page.tsx
new file mode 100644
index 00000000..a01906d8
--- /dev/null
+++ b/write/src/app/edit/[slug]/page.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useState, useEffect, use } from "react";
+import { useRouter } from "next/navigation";
+import { ArrowLeft, Eye } from "lucide-react";
+import PostEditor from "@/components/PostEditor";
+import { HUGO_PREVIEW } from "@/lib/config";
+import type { Post } from "@/lib/posts";
+
+export default function EditPostPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const router = useRouter();
+ const { slug } = use(params);
+ const [post, setPost] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ async function load() {
+ try {
+ const res = await fetch(`/api/posts/${slug}`);
+ if (!res.ok) throw new Error("文章未找到");
+ setPost(await res.json());
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ }
+ load();
+ }, [slug]);
+
+ if (loading) {
+ return (
+
+
+
+ 编辑文章
+
+ 加载中...
+
+ );
+ }
+
+ if (error || !post) {
+ return (
+
+
+
+ 编辑文章
+
+ {error || "文章未找到"}
+
+ );
+ }
+
+ return (
+
+
+
+
+ 编辑文章
+
+
+
+ {post.frontMatter.title}
+
+
+ );
+}
diff --git a/write/src/app/edit/page.tsx b/write/src/app/edit/page.tsx
new file mode 100644
index 00000000..cd0d22d5
--- /dev/null
+++ b/write/src/app/edit/page.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { ArrowLeft } from "lucide-react";
+import PostEditor from "@/components/PostEditor";
+
+export default function NewPostPage() {
+ const router = useRouter();
+
+ return (
+
+
+
+ 撰写文章
+
+
+
+ );
+}
diff --git a/write/src/app/favicon.ico b/write/src/app/favicon.ico
new file mode 100644
index 00000000..718d6fea
Binary files /dev/null and b/write/src/app/favicon.ico differ
diff --git a/write/src/app/feeds/page.tsx b/write/src/app/feeds/page.tsx
new file mode 100644
index 00000000..6f39d457
--- /dev/null
+++ b/write/src/app/feeds/page.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { useRouter } from "next/navigation";
+import { ArrowLeft, Plus, Trash2, Loader2, Wifi, WifiOff, Edit, X } from "lucide-react";
+
+interface Feed {
+ url: string;
+ feedTitle: string;
+ siteUrl?: string;
+ error?: string;
+}
+
+interface FeedHealth {
+ url: string;
+ status: number;
+ ok: boolean;
+ latency?: number;
+ error?: string;
+}
+
+export default function FeedsPage() {
+ const router = useRouter();
+ const [feeds, setFeeds] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [health, setHealth] = useState |