0520发布文章
This commit is contained in:
1 parent
993ac5581d
commit
013dce4f9f
89 files changed
+11923
-54
No files matched your search
@@ -0,0 +1,366 @@
|
||||
"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 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)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user