Files
blog/write/src/app/links/page.tsx
T
2026-06-01 17:57:22 +08:00

310 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Plus, Trash2, Eye, EyeOff, Edit, Wifi, WifiOff, Loader2, X } from "lucide-react";
interface Link {
name: string;
url: string;
description?: string;
image?: string;
hidden?: boolean;
rss?: string;
addedAt?: string;
}
export default function LinksPage() {
const router = useRouter();
const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true);
const [showAll, setShowAll] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Add form
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ name: "", url: "", description: "" });
const [adding, setAdding] = useState(false);
// Edit form
const [editTarget, setEditTarget] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ name: "", url: "", description: "" });
const [editing, setEditing] = useState(false);
// Health
const [health, setHealth] = useState<Map<string, { ok: boolean; status?: number; latency?: number }>>(new Map());
const [checkingAll, setCheckingAll] = useState(false);
const [checkingMap, setCheckingMap] = useState<Map<string, boolean>>(new Map());
const fetchLinks = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/rss/links?all=1`); // always get all for admin
const data = await res.json();
const all = data.links || [];
// Sort visible first, then hidden
all.sort((a: Link, b: Link) => (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0));
setLinks(all);
} catch { setMessage({ type: "error", text: "加载失败" }); }
finally { setLoading(false); }
}, []);
useEffect(() => { fetchLinks(); }, [fetchLinks]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!addForm.name || !addForm.url) return;
setAdding(true);
try {
const res = await fetch("/api/rss/links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(addForm),
});
if (!res.ok) throw new Error("添加失败");
setShowAdd(false);
setAddForm({ name: "", url: "", description: "" });
fetchLinks();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "添加失败" });
} finally { setAdding(false); }
};
const startEdit = (l: Link) => {
setEditTarget(l.url);
setEditForm({ name: l.name, url: l.url, description: l.description || "" });
};
const handleEdit = async () => {
if (!editTarget) return;
setEditing(true);
try {
const body = {
oldUrl: editTarget,
name: editForm.name,
url: editForm.url,
description: editForm.description,
};
const res = await fetch("/api/rss/links", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("保存失败");
setEditTarget(null);
fetchLinks();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
} finally { setEditing(false); }
};
const toggleHidden = async (l: Link) => {
try {
const res = await fetch("/api/rss/links", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
oldUrl: l.url,
name: l.name,
description: l.description || "",
hidden: !l.hidden,
}),
});
if (!res.ok) throw new Error("操作失败");
setLinks((prev) => prev.map((x) => x.url === l.url ? { ...x, hidden: !x.hidden } : x));
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "操作失败" });
}
};
const handleDelete = async (url: string, name: string) => {
if (!confirm(`确定删除「${name}」?`)) return;
try {
await fetch(`/api/rss/links?url=${encodeURIComponent(url)}`, { method: "DELETE" });
setLinks((prev) => prev.filter((l) => l.url !== url));
} catch { setMessage({ type: "error", text: "删除失败" }); }
};
const checkOne = async (url: string) => {
setCheckingMap((prev) => new Map(prev).set(url, true));
try {
const res = await fetch(`/api/rss/links?health=${encodeURIComponent(url)}`);
const data = await res.json();
setHealth((prev) => new Map(prev).set(url, data));
} catch {} finally {
setCheckingMap((prev) => { const m = new Map(prev); m.delete(url); return m; });
}
};
const checkAll = async () => {
setCheckingAll(true);
const visible = links.filter((l) => !l.hidden);
for (let i = 0; i < visible.length; i += 3) {
const batch = visible.slice(i, i + 3);
await Promise.all(batch.map((l) => checkOne(l.url)));
}
setCheckingAll(false);
};
// Counts for display
const visibleLinks = showAll ? links : links.filter((l) => !l.hidden);
const hiddenCount = links.filter((l) => l.hidden).length;
return (
<div className="px-4 py-6 md:px-8 md:py-8">
<div className="flex items-center justify-between mb-6 sm:mb-8 gap-3 flex-wrap">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">友链管理</h2>
<span className="text-[13px] text-muted">{links.length} 个{hiddenCount > 0 && `(${hiddenCount} 隐藏)`}</span>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={checkAll} disabled={checkingAll}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent transition-colors disabled:opacity-50">
{checkingAll ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
{checkingAll ? "检测中..." : "检测存活"}
</button>
<button type="button" onClick={() => setShowAll(!showAll)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border transition-colors ${
showAll ? "bg-accent/5 border-accent/30 text-accent" : "border-line text-muted hover:text-accent"
}`}>
{showAll ? <Eye size={13} /> : <EyeOff size={13} />}
{showAll ? "全部" : "可见"}
</button>
<button type="button" onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors">
<Plus size={13} /> 添加
</button>
</div>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] flex items-center justify-between ${
message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="underline">关闭</button>
</div>
)}
{/* Add form */}
{showAdd && (
<div className="mb-6 p-5 bg-card border border-line rounded-lg">
<form onSubmit={handleAdd} className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">网站名称</label>
<input type="text" value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" required />
</div>
<div className="flex-[2]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">URL</label>
<input type="text" value={addForm.url} onChange={(e) => setAddForm({ ...addForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" required />
</div>
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">描述</label>
<input type="text" value={addForm.description} onChange={(e) => setAddForm({ ...addForm, description: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" />
</div>
<button type="submit" disabled={adding} className="px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 shrink-0" title="添加">
{adding ? "..." : "添加"}
</button>
<button type="button" onClick={() => setShowAdd(false)} className="px-3 py-2 text-[13px] rounded-md border border-line text-muted hover:bg-surface shrink-0" title="取消">取消</button>
</form>
</div>
)}
{/* Loading */}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : visibleLinks.length === 0 ? (
<div className="text-center py-20 text-muted text-sm">暂无友链</div>
) : (
<div className="space-y-2">
{visibleLinks.map((l) => {
const h = health.get(l.url);
const ck = checkingMap.get(l.url);
return (
<div key={l.url}
className={`flex items-center gap-4 px-5 py-3.5 border rounded-md group transition-all ${
l.hidden ? "bg-surface/50 border-line/50 opacity-60" : "bg-card border-line"
} ${editTarget === l.url ? "ring-2 ring-accent/20 border-accent" : ""}`}>
{/* Health indicator */}
{ck ? <Loader2 size={13} className="animate-spin text-muted shrink-0" />
: h ? (h.ok ? <span title={`${h.status} ${h.latency}ms`}><Wifi size={13} className="text-success shrink-0" /></span>
: <span title={h.status ? String(h.status) : "不可达"}><WifiOff size={13} className="text-danger shrink-0" /></span>)
: null}
{/* Favicon */}
{l.image && <img src={l.image} alt="" className="w-4 h-4 rounded shrink-0" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />}
{/* Info or Edit form */}
{editTarget === l.url ? (
<div className="flex-1 flex flex-col sm:flex-row gap-2 sm:items-end">
<div className="flex-1">
<input type="text" value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="名称" placeholder="名称" />
</div>
<div className="flex-[2]">
<input type="text" value={editForm.url} onChange={(e) => setEditForm({ ...editForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="URL" placeholder="URL" />
</div>
<div className="flex-1">
<input type="text" value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" placeholder="描述" title="描述" />
</div>
<button type="button" onClick={handleEdit} disabled={editing}
className="px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover shrink-0" title="保存">{editing ? "..." : "保存"}</button>
<button type="button" onClick={() => setEditTarget(null)}
className="p-1.5 rounded text-muted hover:text-text" title="取消"><X size={14} /></button>
</div>
) : (
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-[14px] text-text">{l.name}</span>
{l.hidden && <span className="text-[10px] text-[#92400e] bg-[#fef3c7] px-1.5 py-px rounded font-medium">隐藏</span>}
{h && !h.ok && <span className="text-[10px] text-danger bg-danger/8 px-1.5 py-px rounded font-medium">失联</span>}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted">
<span className="truncate">{l.url}</span>
{l.description && <span className="text-line">·</span>}
{l.description && <span className="truncate">{l.description}</span>}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-0.5 shrink-0">
{editTarget !== l.url && (
<>
<button type="button" onClick={() => checkOne(l.url)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-accent transition-colors"
title="检测">
{checkingMap.get(l.url) ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
</button>
<button type="button" onClick={() => startEdit(l)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors"
title="编辑">
<Edit size={13} />
</button>
<button type="button" onClick={() => toggleHidden(l)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors"
title={l.hidden ? "显示" : "隐藏"}>
{l.hidden ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
<button type="button" onClick={() => handleDelete(l.url, l.name)} className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors"
title="删除">
<Trash2 size={13} />
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}