0520发布文章

This commit is contained in:
zqlit committed 2026-05-21 00:27:35 +08:00
1 parent 993ac5581d
commit 013dce4f9f
89 files changed
+11923 -54

No files matched your search

+175
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+366
View File
@@ -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" + `![](${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 () => {
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>
);
}
+70
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}