0520发布文章
This commit is contained in:
1 parent
993ac5581d
commit
013dce4f9f
89 files changed
+11923
-54
No files matched your search
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Sparkles, Loader2, Check, Copy, Shrink, Zap, Wand2, ListFilter, FileText } from "lucide-react";
|
||||
|
||||
interface AIPanelProps {
|
||||
// For text operations (polish/expand/shorten/fix)
|
||||
selectedText: string;
|
||||
onApplyText: (text: string) => void;
|
||||
// For summary/tags generation
|
||||
title: string;
|
||||
content: string;
|
||||
onApplyMeta?: (data: { summary?: string; tags?: string }) => void;
|
||||
}
|
||||
|
||||
export default function AIPanel({ selectedText, onApplyText, title, content, onApplyMeta }: AIPanelProps) {
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const runAction = async (action: string) => {
|
||||
setLoading(action);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const body: Record<string, string> = { action };
|
||||
if (["polish", "expand", "shorten", "fix"].includes(action) && selectedText) {
|
||||
body.text = selectedText;
|
||||
} else if (["summary", "tags"].includes(action)) {
|
||||
body.title = title;
|
||||
body.content = content;
|
||||
}
|
||||
const res = await fetch("/api/ai", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "AI error");
|
||||
const data = await res.json();
|
||||
setResult(data.result);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
if (!result) return;
|
||||
if (loading && ["summary", "tags"].includes(loading)) {
|
||||
// For tags: parse and apply
|
||||
if (loading === "tags" && onApplyMeta) {
|
||||
onApplyMeta({ tags: result });
|
||||
} else if (loading === "summary" && onApplyMeta) {
|
||||
onApplyMeta({ summary: result });
|
||||
}
|
||||
} else {
|
||||
onApplyText(result);
|
||||
}
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const textActions = [
|
||||
{ id: "polish", label: "润色", icon: Wand2, desc: "让文字更流畅" },
|
||||
{ id: "expand", label: "扩写", icon: Zap, desc: "增加细节论述" },
|
||||
{ id: "shorten", label: "缩写", icon: Shrink, desc: "精简核心信息" },
|
||||
{ id: "fix", label: "纠错", icon: Check, desc: "修正错别字语法" },
|
||||
];
|
||||
|
||||
const metaActions = [
|
||||
{ id: "summary", label: "生成摘要", icon: FileText },
|
||||
{ id: "tags", label: "提取标签", icon: ListFilter },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-medium text-muted uppercase tracking-wider mb-3">AI 助手</h3>
|
||||
|
||||
<div className="bg-white border border-line rounded-lg overflow-hidden">
|
||||
{/* Text operations */}
|
||||
<div className="p-3 border-b border-line/50">
|
||||
<div className="text-[10px] font-medium text-muted/70 uppercase mb-2">文本处理</div>
|
||||
<div className="space-y-1">
|
||||
{textActions.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
disabled={!!loading}
|
||||
onClick={() => runAction(a.id)}
|
||||
className={`w-full flex items-center gap-2 px-2.5 py-1.5 text-[12px] rounded transition-colors ${
|
||||
selectedText
|
||||
? "text-text/70 hover:text-accent hover:bg-accent/4"
|
||||
: "text-muted/30 cursor-not-allowed"
|
||||
}`}
|
||||
title={selectedText ? a.desc : "请先选中文字"}
|
||||
>
|
||||
{loading === a.id ? (
|
||||
<Loader2 size={13} className="animate-spin shrink-0" />
|
||||
) : (
|
||||
<a.icon size={13} className="shrink-0" />
|
||||
)}
|
||||
<span>{a.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta generation */}
|
||||
<div className="p-3">
|
||||
<div className="text-[10px] font-medium text-muted/70 uppercase mb-2">元信息生成</div>
|
||||
<div className="space-y-1">
|
||||
{metaActions.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
disabled={!!loading || !title}
|
||||
onClick={() => runAction(a.id)}
|
||||
className={`w-full flex items-center gap-2 px-2.5 py-1.5 text-[12px] rounded transition-colors ${
|
||||
title
|
||||
? "text-text/70 hover:text-accent hover:bg-accent/4"
|
||||
: "text-muted/30 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{loading === a.id ? (
|
||||
<Loader2 size={13} className="animate-spin shrink-0" />
|
||||
) : (
|
||||
<a.icon size={13} className="shrink-0" />
|
||||
)}
|
||||
<span>{a.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result panel */}
|
||||
{(result || error) && (
|
||||
<div className="mt-3 p-3 bg-white border border-line rounded-lg">
|
||||
{error ? (
|
||||
<div className="text-[12px] text-danger">{error}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[12px] text-text/80 leading-relaxed max-h-[200px] overflow-y-auto whitespace-pre-wrap">
|
||||
{result}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={apply}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Copy size={11} /> 应用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResult(null)}
|
||||
className="px-2.5 py-1 text-[11px] rounded border border-line text-muted hover:bg-surface transition-colors"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API key tip */}
|
||||
{!selectedText && !title && (
|
||||
<p className="mt-3 text-[10px] text-muted/50 leading-relaxed">
|
||||
选中文字后可使用润色/扩写/缩写/纠错。填写标题后可生成摘要和标签。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages: number[] = [];
|
||||
const maxShow = 7;
|
||||
let start = Math.max(1, page - Math.floor(maxShow / 2));
|
||||
const end = Math.min(totalPages, start + maxShow - 1);
|
||||
start = Math.max(1, end - maxShow + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
|
||||
const btn = "px-2.5 py-1.5 text-[12px] rounded-md border border-line hover:bg-surface transition-colors";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1 mt-8">
|
||||
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}
|
||||
className={`${btn} ${page <= 1 ? "opacity-25 cursor-not-allowed" : ""}`}>上一页</button>
|
||||
{start > 1 && (
|
||||
<>
|
||||
<button type="button" onClick={() => onPageChange(1)} className={btn}>1</button>
|
||||
{start > 2 && <span className="px-1 text-[12px] text-muted">...</span>}
|
||||
</>
|
||||
)}
|
||||
{pages.map((p) => (
|
||||
<button type="button" key={p} onClick={() => onPageChange(p)}
|
||||
className={`px-2.5 py-1.5 text-[12px] rounded-md border ${
|
||||
p === page ? "bg-accent text-white border-accent font-medium" : `border-line hover:bg-surface`
|
||||
}`}>{p}</button>
|
||||
))}
|
||||
{end < totalPages && (
|
||||
<>
|
||||
{end < totalPages - 1 && <span className="px-1 text-[12px] text-muted">...</span>}
|
||||
<button type="button" onClick={() => onPageChange(totalPages)} className={btn}>{totalPages}</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}
|
||||
className={`${btn} ${page >= totalPages ? "opacity-25 cursor-not-allowed" : ""}`}>下一页</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { RefObject } from "react";
|
||||
import { Heading2, Heading3, Code, Quote, Image, Link, Minus, Bold, FileCode } from "lucide-react";
|
||||
|
||||
interface ShortcutPanelProps {
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function ShortcutPanel({ textareaRef, value, onChange }: ShortcutPanelProps) {
|
||||
const insert = (before: string, after: string, placeholder: string) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) {
|
||||
onChange(value + before + placeholder + after);
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const selected = value.slice(start, end) || placeholder;
|
||||
const newVal = value.slice(0, start) + before + selected + after + value.slice(end);
|
||||
onChange(newVal);
|
||||
setTimeout(() => {
|
||||
const pos = start + before.length + selected.length + after.length;
|
||||
el.setSelectionRange(start + before.length, pos);
|
||||
el.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const shortcuts = [
|
||||
{ label: "H2", icon: Heading2, action: () => insert("## ", "\n", "二级标题") },
|
||||
{ label: "H3", icon: Heading3, action: () => insert("### ", "\n", "三级标题") },
|
||||
{ label: "粗体", icon: Bold, action: () => insert("**", "**", "粗体文本") },
|
||||
{ label: "代码块", icon: FileCode, action: () => insert("```\n", "\n```\n", "代码") },
|
||||
{ label: "行内代码", icon: Code, action: () => insert("`", "`", "code") },
|
||||
{ label: "引用", icon: Quote, action: () => insert("> ", "\n", "引用内容") },
|
||||
{ label: "图片", icon: Image, action: () => insert("![", "]()", "描述") },
|
||||
{ label: "链接", icon: Link, action: () => insert("[", "]()", "链接文本") },
|
||||
{ label: "分割线", icon: Minus, action: () => insert("\n---\n", "", "") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-medium text-muted uppercase tracking-wider mb-3">快捷插入</h3>
|
||||
<div className="bg-white border border-line rounded-lg p-3 space-y-1">
|
||||
{shortcuts.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
onClick={s.action}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 text-[12px] text-text/70 hover:text-accent hover:bg-accent/4 rounded transition-colors"
|
||||
title={s.label}
|
||||
>
|
||||
<s.icon size={14} className="shrink-0" />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-surface border border-line/50 rounded-lg">
|
||||
<h4 className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">说明</h4>
|
||||
<ul className="text-[11px] text-muted/70 space-y-1 leading-relaxed">
|
||||
<li>选中文字后点按钮可包裹</li>
|
||||
<li>未选中则插入模板 + 占位文字</li>
|
||||
<li>代码块自动补全语言标注</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image } from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "文章", icon: FileText },
|
||||
{ href: "/edit", label: "撰写", icon: PenSquare },
|
||||
{ href: "/comments", label: "评论", icon: MessageSquare },
|
||||
{ href: "/feeds", label: "订阅源", icon: Rss },
|
||||
{ href: "/links", label: "友链", icon: Link2 },
|
||||
{ href: "/images", label: "图片", icon: Image },
|
||||
{ href: "/wechat", label: "公众号", icon: MessageCircle },
|
||||
{ href: "/recycle", label: "回收站", icon: Trash2 },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-1/4 min-w-[200px] max-w-[260px] bg-surface border-r border-line flex flex-col shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-5 border-b border-line">
|
||||
<h1 className="text-[15px] font-bold text-accent tracking-wider">
|
||||
写作后台
|
||||
</h1>
|
||||
<p className="text-[11px] text-muted/70 mt-0.5">优世界</p>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-2.5 py-3 space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] transition-colors ${
|
||||
active
|
||||
? "bg-accent text-white font-medium"
|
||||
: "text-text/80 hover:bg-bg hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
<item.icon size={15} strokeWidth={active ? 2.5 : 1.8} />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-line">
|
||||
<p className="text-[10px] text-muted/50">localhost:8016</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user