Files
jiang13-bbs/frontend/app/admin/pinned/PinnedAdmin.tsx

554 lines
18 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 {
useCallback,
useEffect,
useRef,
useState,
type DragEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import {
Check,
ChevronDown,
ChevronUp,
FolderOpen,
Globe,
GripVertical,
Loader2,
Pin,
} from "lucide-react";
import {
apiAdminPinnedPosts,
apiAdminSavePinOrder,
type AdminPinnedPost,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
} from "@/components/admin";
interface BoardOpt {
id: number;
name: string;
}
interface PinnedAdminProps {
boards: BoardOpt[];
/** 管理员及以上:可排全站置顶 */
canGlobal: boolean;
}
type Level = 1 | 2;
export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
const [level, setLevel] = useState<Level>(canGlobal ? 2 : 1);
const [boardId, setBoardId] = useState<number>(boards[0]?.id ?? 0);
const [posts, setPosts] = useState<AdminPinnedPost[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
/** 拖拽排序:被拖行下标 + 插入位置(0..N,N 表示末尾) */
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const load = useCallback(async (lv: Level, bid: number) => {
setLoading(true);
setError("");
try {
const r = await apiAdminPinnedPosts(lv, lv === 1 ? bid : undefined);
setPosts(r.posts ?? []);
} catch (e) {
setPosts([]);
setError(e instanceof Error ? e.message : "获取置顶列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(level, boardId);
}, [level, boardId, load]);
const switchLevel = (lv: Level) => {
if (lv === level) return;
setLevel(lv);
setPosts([]);
setError("");
};
/** 统一保存:乐观更新新顺序,失败回滚并提示 */
const saveOrder = useCallback(
async (next: AdminPinnedPost[], prev: AdminPinnedPost[]) => {
setPosts(next);
setSaving(true);
try {
await apiAdminSavePinOrder(
level,
level === 1 ? boardId : 0,
next.map((p) => p.id)
);
toast("置顶顺序已更新", "ok");
} catch (e) {
setPosts(prev);
toast(e instanceof Error ? e.message : "保存顺序失败");
} finally {
setSaving(false);
}
},
[level, boardId]
);
// 上移/下移按钮与键盘共用
const move = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= posts.length || saving) return;
const prev = posts;
const next = [...posts];
const tmp = next[idx];
next[idx] = next[target];
next[target] = tmp;
void saveOrder(next, prev);
};
const resetDrag = () => {
setDragIndex(null);
setDropIndex(null);
};
const onRowDragOver = (e: DragEvent<HTMLLIElement>, i: number) => {
if (dragIndex === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// 指针过行中线则插到行后,否则插到行前
const rect = e.currentTarget.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
setDropIndex(i + (after ? 1 : 0));
};
const onRowDrop = (e: DragEvent<HTMLLIElement>) => {
e.preventDefault();
if (dragIndex === null || dropIndex === null || saving) {
resetDrag();
return;
}
// 落在原位置前后:无变化
if (dropIndex === dragIndex || dropIndex === dragIndex + 1) {
resetDrag();
return;
}
const prev = posts;
const next = [...posts];
const [moved] = next.splice(dragIndex, 1);
next.splice(dropIndex > dragIndex ? dropIndex - 1 : dropIndex, 0, moved);
resetDrag();
void saveOrder(next, prev);
};
/** 插入指示线:仅当落点确实改变位置时显示 */
const showLineAt = (pos: number) =>
dragIndex !== null && dropIndex === pos && pos !== dragIndex && pos !== dragIndex + 1;
const segOptions = [
...(canGlobal
? [{ key: "2", label: "全站置顶", icon: <Globe size={13} aria-hidden /> }]
: []),
{ key: "1", label: "板块置顶", icon: <FolderOpen size={13} aria-hidden /> },
];
return (
<div>
<AdminPageHeader
icon={Pin}
title="置顶管理"
description="拖拽手柄或上下箭头调整置顶帖顺序,改动自动保存,越靠前越先展示。板块管理员仅可排序授权板块的板块置顶。"
/>
{error && (
<div
className="mb-3 px-3 py-2 rounded-[var(--r-sm)] text-[12.5px]"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
{error}
</div>
)}
{/* 层级切换 + 板块选择 */}
<div className="mb-4 flex items-center gap-3 flex-wrap">
<AdminSegmented
options={segOptions}
value={String(level)}
onChange={(k) => switchLevel(Number(k) as Level)}
ariaLabel="置顶层级"
/>
{level === 1 && (
<BoardSelect
boards={boards}
value={boardId}
onChange={setBoardId}
disabled={loading || saving}
/>
)}
</div>
<div className="panel overflow-hidden">
{loading ? (
<PinListSkeleton />
) : posts.length === 0 ? (
<AdminEmpty
icon={<Pin size={20} />}
title={level === 2 ? "暂无全站置顶帖" : "该板块暂无置顶帖"}
description="在帖子页的「管理」菜单中可将帖子设为置顶。"
/>
) : (
<>
{/* 列表状态条:数量 + 保存状态/操作提示 */}
<div
className="flex items-center justify-between gap-3 px-4 py-2.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span
className="text-[12.5px] font-semibold inline-flex items-center gap-1"
style={{ color: "var(--ink-2)" }}
>
共
<b className="tabular-nums" style={{ color: "var(--accent)" }}>
{posts.length}
</b>
篇置顶
</span>
<span className="meta text-[12px] inline-flex items-center gap-1.5">
{saving ? (
<>
<Loader2 size={12} className="animate-spin" aria-hidden />
保存中…
</>
) : (
"拖拽手柄或上下按钮调整,自动保存"
)}
</span>
</div>
<ul
onDragLeave={(e) => {
// 仅当指针真正离开列表时清除指示线,避免行间移动时闪烁
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDropIndex(null);
}
}}
>
{posts.map((p, i) => (
<li
key={p.id}
className={`pin-row relative flex items-center gap-3 px-4 py-3${
dragIndex === i ? " is-dragging" : ""
}`}
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
onDragOver={(e) => onRowDragOver(e, i)}
onDrop={onRowDrop}
>
{/* 拖拽插入位置指示线 */}
{showLineAt(i) && (
<span
className="absolute left-3 right-3 -top-px h-[2px] rounded-full pointer-events-none"
style={{ background: "var(--accent)" }}
aria-hidden
/>
)}
{i === posts.length - 1 && showLineAt(posts.length) && (
<span
className="absolute left-3 right-3 -bottom-px h-[2px] rounded-full pointer-events-none"
style={{ background: "var(--accent)" }}
aria-hidden
/>
)}
{/* 拖拽手柄:键盘上下方向键同样可排序 */}
<span
role="button"
tabIndex={0}
aria-label={`拖拽调整「${p.title}」的顺序,也可使用上下方向键`}
className="pin-drag-handle w-5 h-8 -ml-1 inline-flex items-center justify-center rounded-md shrink-0"
draggable={!saving}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(i));
setDragIndex(i);
}}
onDragEnd={resetDrag}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
move(i, -1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
move(i, 1);
}
}}
>
<GripVertical size={14} aria-hidden />
</span>
{/* 名次徽章:前三名高亮 */}
<span
className="w-7 h-7 rounded-lg inline-flex items-center justify-center text-[12.5px] font-bold tabular-nums shrink-0"
style={
i < 3
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { background: "var(--panel-2)", color: "var(--ink-3)" }
}
>
{i + 1}
</span>
<div className="min-w-0 flex-1">
<a
href={`/post/${p.id}`}
target="_blank"
rel="noreferrer"
className="text-[13.5px] font-semibold truncate block hover:underline"
style={{ color: "var(--ink)" }}
>
{p.title}
</a>
<p className="meta text-[12px] mt-0.5 flex items-center gap-1.5 truncate">
{level === 2 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-px rounded-full shrink-0"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<FolderOpen size={10} aria-hidden />
{p.board_name}
</span>
)}
<span className="shrink-0">{formatRelative(p.created_at)}</span>
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<AdminIconButton
ariaLabel={`上移「${p.title}」`}
tip="上移"
disabled={i === 0 || saving}
onClick={() => move(i, -1)}
>
<ChevronUp size={15} />
</AdminIconButton>
<AdminIconButton
ariaLabel={`下移「${p.title}」`}
tip="下移"
disabled={i === posts.length - 1 || saving}
onClick={() => move(i, 1)}
>
<ChevronDown size={15} />
</AdminIconButton>
</div>
</li>
))}
</ul>
</>
)}
</div>
</div>
);
}
/** 板块自定义下拉:浮层 + 键盘导航 + 滚动列表,替代原生 select */
function BoardSelect({
boards,
value,
onChange,
disabled,
}: {
boards: BoardOpt[];
value: number;
onChange: (id: number) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
/** 键盘/悬停高亮下标 */
const [hi, setHi] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const current = boards.find((b) => b.id === value);
useEffect(() => {
if (!open) return;
setHi(Math.max(0, boards.findIndex((b) => b.id === value)));
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open, boards, value]);
// 高亮项保持在可视区内
useEffect(() => {
if (!open) return;
const el = listRef.current?.children[hi];
el?.scrollIntoView({ block: "nearest" });
}, [hi, open]);
const close = (refocus = false) => {
setOpen(false);
if (refocus) triggerRef.current?.focus();
};
const select = (id: number) => {
onChange(id);
close(true);
};
const onKeyDown = (e: ReactKeyboardEvent) => {
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
e.preventDefault();
setOpen(true);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
close(true);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHi((v) => Math.min(boards.length - 1, v + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHi((v) => Math.max(0, v - 1));
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (boards[hi]) select(boards[hi].id);
} else if (e.key === "Tab") {
close();
}
};
if (boards.length === 0) {
return <span className="meta text-[13px] py-2">无可用板块</span>;
}
return (
<div ref={ref} className="relative" onKeyDown={onKeyDown}>
<button
ref={triggerRef}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label="选择板块"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="pin-board-trigger h-9 pl-3 pr-2.5 rounded-lg inline-flex items-center gap-2 text-[13px] font-medium transition-[border-color,box-shadow] disabled:opacity-60 disabled:cursor-not-allowed"
style={{
minWidth: 176,
background: "var(--panel)",
border: `1px solid ${open ? "var(--accent)" : "var(--line-2)"}`,
color: "var(--ink)",
boxShadow: open ? "0 0 0 3px var(--accent-soft)" : "var(--shadow-sm)",
}}
>
<FolderOpen
size={14}
className="shrink-0 transition-colors"
style={{ color: open ? "var(--accent)" : "var(--ink-3)" }}
aria-hidden
/>
<span className="truncate max-w-44">{current?.name ?? "选择板块"}</span>
<ChevronDown
size={13}
className={`ml-auto shrink-0 transition-transform duration-150${
open ? " rotate-180" : ""
}`}
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
</button>
{open && (
<div
className="absolute z-30 mt-1.5 left-0 w-full min-w-52 rounded-xl overflow-hidden"
style={{
background: "var(--panel)",
border: "1px solid var(--line-2)",
boxShadow: "var(--shadow-lg)",
}}
>
<ul
ref={listRef}
role="listbox"
aria-label="板块列表"
className="max-h-60 overflow-y-auto p-1.5"
>
{boards.map((b, i) => {
const active = b.id === value;
return (
<li key={b.id}>
<button
type="button"
role="option"
aria-selected={active}
tabIndex={-1}
onClick={() => select(b.id)}
onMouseEnter={() => setHi(i)}
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-left text-[13px] transition-colors"
style={{
background: active
? "var(--nav-active-bg)"
: i === hi
? "var(--panel-2)"
: undefined,
color: active ? "var(--nav-active-ink)" : "var(--ink-2)",
}}
>
<span className="flex-1 truncate font-medium">{b.name}</span>
{active && <Check size={13} className="shrink-0" aria-hidden />}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
/** 加载骨架:模拟 手柄/徽章/标题/摘要 行结构 */
function PinListSkeleton() {
return (
<ul aria-hidden>
{[0, 1, 2, 3].map((i) => (
<li
key={i}
className="flex items-center gap-3 px-4 py-3"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
<span
className="w-5 h-7 -ml-1 rounded-md animate-pulse shrink-0"
style={{ background: "var(--panel-2)" }}
/>
<span
className="w-7 h-7 rounded-lg animate-pulse shrink-0"
style={{ background: "var(--panel-2)" }}
/>
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<span
className="h-3.5 rounded animate-pulse"
style={{ background: "var(--panel-2)", width: `${52 - i * 9}%` }}
/>
<span
className="h-3 w-28 rounded animate-pulse"
style={{ background: "var(--panel-2)" }}
/>
</div>
</li>
))}
</ul>
);
}