"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(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(null); const textareaRef = useRef(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) => { 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) => { 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 => { const frontMatter: Record = { 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 ( <>
{message && (
{message.text}
)} {/* Section: 元信息 */}

文章信息

{ 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="文章标题" />
setForm({ ...form, slug: e.target.value })} className={inputCls} placeholder="中文-slug" />
setForm({ ...form, date: e.target.value })} className={inputCls} title="日期" />
setForm({ ...form, author: e.target.value })} className={inputCls} title="作者" />
setForm({ ...form, categories: e.target.value })} className={inputCls} placeholder="用顿号分隔" />
setForm({ ...form, tags: e.target.value })} className={inputCls} placeholder="用顿号分隔" />
setForm({ ...form, ai_comment: e.target.value })} className={inputCls} placeholder="可选" />
{/* Folder name preview */}
文件夹:{form.date}-{form.title.replace(/[^\w一-鿿]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "untitled"}
{/* Section: 正文 */}

正文 · Markdown