388 lines
15 KiB
TypeScript
388 lines
15 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);
|
|
|
|
// 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 = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
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" + `` + "\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 () => {
|
|
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-white 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 gap-8">
|
|
<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 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-[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 gap-3">
|
|
<div className="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-[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 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">
|
|
<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={handlePublish}
|
|
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-[180px] shrink-0">
|
|
<div className="sticky 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>
|
|
);
|
|
}
|