Files
blog/write/src/components/PostEditor.tsx
T
2026-06-01 17:57:22 +08:00

469 lines
19 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Image, Loader2, Send } from "lucide-react";
import type { PostFrontMatter } from "@/lib/posts";
import ShortcutPanel from "./ShortcutPanel";
import AIPanel from "./AIPanel";
interface PostEditorProps {
initialData?: { frontMatter: PostFrontMatter; content: string; dirPath?: string };
isEditing?: boolean;
savedDirPath?: string;
}
export default function PostEditor({ initialData, isEditing }: PostEditorProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [dirPath, setDirPath] = useState<string | null>(initialData?.dirPath || null);
const today = new Date().toISOString().slice(0, 10);
const [form, setForm] = useState({
title: initialData?.frontMatter.title || "",
slug: initialData?.frontMatter.slug || "",
date: initialData?.frontMatter.date ? new Date(initialData.frontMatter.date).toISOString().slice(0, 10) : today,
categories: initialData?.frontMatter.categories?.join("、") || "",
tags: initialData?.frontMatter.tags?.join("、") || "",
author: initialData?.frontMatter.author || "小赵",
ai_comment: initialData?.frontMatter.ai_comment || "",
});
const [content, setContent] = useState(initialData?.content || "");
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [selectedText, setSelectedText] = useState("");
const [autoSaved, setAutoSaved] = useState(false);
const [showPublishConfirm, setShowPublishConfirm] = useState(false);
// Auto-save: restore from localStorage on mount
const cacheKey = isEditing ? `draft-edit-${initialData?.frontMatter.slug}` : "draft-new";
useEffect(() => {
if (initialData?.content) return; // Don't override loaded content
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const data = JSON.parse(cached);
if (data.title) setForm((f) => ({ ...f, ...data.form }));
if (data.content) setContent(data.content);
if (data.dirPath) setDirPath(data.dirPath);
setAutoSaved(true);
}
} catch {}
}, []);
// Auto-save every 5 seconds
useEffect(() => {
const timer = setInterval(() => {
if (!form.title && !content) return;
try {
localStorage.setItem(cacheKey, JSON.stringify({
form: { title: form.title, slug: form.slug, date: form.date, categories: form.categories, tags: form.tags, author: form.author, ai_comment: form.ai_comment },
content,
dirPath,
savedAt: Date.now(),
}));
setAutoSaved(true);
} catch {}
}, 5000);
return () => clearInterval(timer);
}, [form, content, dirPath, cacheKey]);
// Clear cache on successful save
const clearCache = () => {
try { localStorage.removeItem(cacheKey); } catch {}
setAutoSaved(false);
};
const handleSelectionChange = () => {
const el = textareaRef.current;
if (el) {
setSelectedText(el.value.substring(el.selectionStart, el.selectionEnd));
}
};
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const imageFile = Array.from(e.clipboardData.files).find((file) => file.type.startsWith("image/"));
if (imageFile) {
e.preventDefault();
if (!dirPath) {
setMessage({ type: "error", text: "请先保存文章,再直接粘贴图片。这样图片才能放到文章同级文件夹。" });
return;
}
const el = e.currentTarget;
const start = el.selectionStart;
const end = el.selectionEnd;
setUploading(true);
setMessage(null);
try {
const fd = new FormData();
fd.append("file", imageFile);
fd.append("dirPath", dirPath);
const res = await fetch("/api/upload", { method: "POST", body: fd });
if (!res.ok) throw new Error("上传失败");
const data = await res.json();
const markdown = `![](${data.url})`;
const nextContent = content.slice(0, start) + markdown + content.slice(end);
const nextCursor = start + markdown.length;
setContent(nextContent);
requestAnimationFrame(() => {
el.focus();
el.selectionStart = nextCursor;
el.selectionEnd = nextCursor;
});
} catch {
setMessage({ type: "error", text: "粘贴图片上传失败" });
} finally {
setUploading(false);
}
return;
}
const text = e.clipboardData.getData("text/plain");
if (!text) return;
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart;
const end = el.selectionEnd;
const nextContent = content.slice(0, start) + text + content.slice(end);
const nextCursor = start + text.length;
setContent(nextContent);
setSelectedText("");
requestAnimationFrame(() => {
el.focus();
el.selectionStart = nextCursor;
el.selectionEnd = nextCursor;
});
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
if (dirPath) fd.append("dirPath", dirPath);
const res = await fetch("/api/upload", { method: "POST", body: fd });
if (!res.ok) throw new Error("上传失败");
const data = await res.json();
setContent((prev) => prev + "\n" + `![](${data.url})` + "\n");
} catch {
setMessage({ type: "error", text: "图片上传失败" });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const savePost = async (draft: boolean): Promise<string | null> => {
const frontMatter: Record<string, unknown> = {
title: form.title,
slug: form.slug,
date: new Date(form.date).toISOString(),
draft,
categories: form.categories.split("、").map((s) => s.trim()).filter(Boolean),
tags: form.tags.split("、").map((s) => s.trim()).filter(Boolean),
author: form.author,
layout: "post",
};
if (form.ai_comment) frontMatter.ai_comment = form.ai_comment;
const url = isEditing ? `/api/posts/${initialData!.frontMatter.slug}` : "/api/posts";
const method = isEditing ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ frontMatter, content }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "保存失败");
}
const data = await res.json();
return data.dirPath || null;
};
const handleDraft = async () => {
setSaving(true); setMessage(null);
try {
const dp = await savePost(true);
if (dp) setDirPath(dp);
setMessage({ type: "success", text: "草稿已保存" });
clearCache();
if (!isEditing) router.push(`/edit/${form.slug}`);
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
} finally { setSaving(false); }
};
const handleSave = async () => {
setSaving(true); setMessage(null);
try {
const dp = await savePost(false);
if (dp) setDirPath(dp);
setMessage({ type: "success", text: "已保存到本地" });
clearCache();
if (!isEditing) router.push(`/edit/${form.slug}`);
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
} finally { setSaving(false); }
};
const handlePublish = async () => {
setShowPublishConfirm(false);
setSaving(true); setMessage(null);
try {
// First save with draft:false
const dp = await savePost(false);
if (dp) setDirPath(dp);
// Then deploy via git
setMessage({ type: "success", text: "已保存,正在推送到 GitHub..." });
const deployRes = await fetch("/api/deploy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: form.title, slug: form.slug }),
});
if (!deployRes.ok) {
const d = await deployRes.json();
throw new Error(d.error || "推送失败");
}
setMessage({ type: "success", text: "已发布并推送到 GitHub" });
clearCache();
setTimeout(() => router.push("/"), 800);
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "发布失败" });
} finally { setSaving(false); }
};
const handleApplyText = (text: string) => {
const el = textareaRef.current;
if (el) {
const start = el.selectionStart;
const end = el.selectionEnd;
const newVal = content.slice(0, start) + text + content.slice(end);
setContent(newVal);
setSelectedText("");
}
};
const handleApplyMeta = (data: { summary?: string; tags?: string }) => {
if (data.tags) setForm((f) => ({ ...f, tags: data.tags || "" }));
if (data.summary) setForm((f) => ({ ...f, ai_comment: data.summary || "" }));
};
const inputCls =
"w-full bg-card border border-line rounded-md px-3 py-2 text-[13px] text-text placeholder:text-muted/35 focus:outline-none focus:border-accent/30 transition-colors";
return (
<>
<div className="flex flex-col lg:flex-row lg:gap-8 gap-6">
<div className="flex-1 min-w-0 space-y-8">
{message && (
<div className={`px-4 py-2.5 rounded-md text-[13px] font-medium ${
message.type === "success"
? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]"
: "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
</div>
)}
{/* Section: 元信息 */}
<section>
<h3 className="text-[11px] font-medium text-muted uppercase tracking-wider mb-4">文章信息</h3>
<div className="space-y-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">标题</label>
<input
type="text"
value={form.title}
onChange={(e) => {
setForm({ ...form, title: e.target.value });
if (!isEditing && !form.slug) {
// Slug: YYYYMMDDHHmm numeric timestamp
const now = new Date();
const s = now.getFullYear().toString() +
String(now.getMonth() + 1).padStart(2, "0") +
String(now.getDate()).padStart(2, "0") +
String(now.getHours()).padStart(2, "0") +
String(now.getMinutes()).padStart(2, "0");
setForm((f) => ({ ...f, slug: s }));
}
}}
className={inputCls}
placeholder="文章标题"
/>
</div>
<div className="w-full sm:w-[200px]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">
Slug <span className="text-[10px] text-muted/50 font-normal">(自动时间戳)</span>
</label>
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCls} placeholder="中文-slug" />
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full sm:w-[180px]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">日期</label>
<input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} className={inputCls} title="日期" />
</div>
<div className="w-full sm:w-[160px]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">作者</label>
<input type="text" value={form.author} onChange={(e) => setForm({ ...form, author: e.target.value })} className={inputCls} title="作者" />
</div>
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">分类</label>
<input type="text" value={form.categories} onChange={(e) => setForm({ ...form, categories: e.target.value })} className={inputCls} placeholder="用顿号分隔" />
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">标签</label>
<input type="text" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} className={inputCls} placeholder="用顿号分隔" />
</div>
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">AI 评论</label>
<input type="text" value={form.ai_comment} onChange={(e) => setForm({ ...form, ai_comment: e.target.value })} className={inputCls} placeholder="可选" />
</div>
</div>
{/* Folder name preview */}
<div className="text-[11px] text-muted/60">
文件夹:{form.date}-{form.title.replace(/[^\w一-鿿]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "untitled"}
</div>
</div>
</section>
{/* Section: 正文 */}
<section>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[11px] font-medium text-muted uppercase tracking-wider">正文 · Markdown</h3>
<label className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent hover:border-accent/30 cursor-pointer transition-colors">
{uploading ? <Loader2 size={13} className="animate-spin" /> : <Image size={13} />}
{uploading ? "上传中..." : (!dirPath ? "先保存再传图" : "插入图片")}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
disabled={!dirPath}
className="hidden"
/>
</label>
</div>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onPaste={handlePaste}
onSelect={handleSelectionChange}
onKeyUp={handleSelectionChange}
onMouseUp={handleSelectionChange}
rows={30}
className={`${inputCls} resize-y font-mono text-[14px] leading-relaxed`}
placeholder="开始撰写..."
/>
</section>
{/* Actions: 草稿 | 保存 | 发布 */}
<div className="flex items-center gap-3 pt-2 flex-wrap">
<button
type="button"
onClick={handleDraft}
disabled={saving}
className="px-4 py-2 text-[13px] rounded-md border border-line text-muted hover:bg-surface disabled:opacity-50 transition-colors"
>
{saving ? "..." : "草稿"}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-[13px] rounded-md border border-line text-text hover:bg-surface disabled:opacity-50 transition-colors"
>
{saving ? "..." : "保存"}
</button>
<button
type="button"
onClick={() => setShowPublishConfirm(true)}
disabled={saving}
className="flex items-center gap-1.5 px-5 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 transition-colors"
>
<Send size={13} />
{saving ? "发布中..." : "发布"}
</button>
</div>
<p className="text-[11px] text-muted/50 -mt-4 flex items-center gap-2">
{autoSaved && <span className="text-success">已自动缓存</span>}
草稿:draft:true 不显示 | 保存:存本地 draft:false | 发布:git push 到 GitHub
</p>
</div>
{/* Right: shortcuts + AI panel */}
<div className="w-full lg:w-[180px] lg:shrink-0">
<div className="lg:sticky lg:top-6 space-y-6">
<ShortcutPanel textareaRef={textareaRef} value={content} onChange={setContent} />
<AIPanel
selectedText={selectedText}
onApplyText={handleApplyText}
title={form.title}
content={content}
onApplyMeta={handleApplyMeta}
/>
</div>
</div>
</div>
{showPublishConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#223d66]/25 px-4 backdrop-blur-sm">
<div className="w-full max-w-[420px] rounded-2xl border border-line bg-bg p-6 shadow-[0_24px_80px_rgba(34,61,102,0.22)]">
<div className="mb-4 flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
<Send size={18} />
</div>
<div>
<h3 className="text-[17px] font-semibold text-text">确认发布到公网?</h3>
<p className="mt-1 text-[13px] leading-6 text-muted">
发布后会自动保存文章、生成 Git 提交,并推送到 GitHub。公网会同步更新,请确认内容已经检查无误。
</p>
</div>
</div>
<div className="rounded-xl border border-line bg-surface/70 px-4 py-3 text-[12px] leading-5 text-muted">
当前文章:<span className="font-medium text-text">{form.title || "未命名文章"}</span>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setShowPublishConfirm(false)}
className="rounded-md border border-line px-4 py-2 text-[13px] text-muted transition-colors hover:bg-surface hover:text-text"
>
取消
</button>
<button
type="button"
onClick={handlePublish}
disabled={saving}
className="flex items-center gap-1.5 rounded-md bg-accent px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
<Send size={13} />
确认发布
</button>
</div>
</div>
</div>
)}
</>
);
}