Files
jiang13-bbs/frontend/app/admin/settings/HeaderNavPanel.tsx

326 lines
12 KiB
TypeScript
Raw Permalink 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 { ArrowDown, ArrowUp, BookOpen, CodeXml, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
import { useEffect, useState } from "react";
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import {
HEADER_NAV_BUILTIN_KEYS,
HEADER_NAV_BUILTIN_LABELS,
HEADER_NAV_LIMITS,
isHeaderNavExternal,
type HeaderNavBuiltinKey,
type HeaderNavItem,
} from "@/lib/headerNav";
import { footerURLAllowed } from "@/lib/brand";
import { toast } from "@/lib/toast";
const BUILTIN_ICONS: Record<HeaderNavBuiltinKey, React.ReactNode> = {
home: <Home size={14} />,
leaderboard: <Trophy size={14} />,
library: <BookOpen size={14} />,
snippets: <CodeXml size={14} />,
};
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
home: "站点首页 /",
leaderboard: "排行榜页(地址随伪静态风格变化)",
library: "书库页(地址随伪静态风格变化)",
snippets: "片段广场页(地址随伪静态风格变化)",
};
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
function clone(items: HeaderNavItem[]): HeaderNavItem[] {
return items.map((item) => ({ ...item }));
}
function switchesOf(items: HeaderNavItem[]): BuiltinSwitches {
const present = new Set(
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
);
return {
home: present.has("home"),
leaderboard: present.has("leaderboard"),
library: present.has("library"),
snippets: present.has("snippets"),
};
}
/** 完整行列表:已启用项按配置顺序在前,未启用的内置项按默认顺序追加在后 */
function rowsOf(items: HeaderNavItem[]): HeaderNavItem[] {
const present = new Set(
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
);
const hidden = HEADER_NAV_BUILTIN_KEYS.filter((key) => !present.has(key)).map((key) => ({
type: "builtin" as const,
key,
}));
return [...clone(items), ...hidden];
}
/**
* 桌面端 Header 品牌名右侧的主导航:排序、开关显隐内置项、增删自定义链接,整体替换保存。
* 与移动端底栏面板同款交互:操作按钮居右,内置项开关隐藏(隐藏项保留排序位置)。
*/
export default function HeaderNavPanel({
initial,
onSaved,
onDirtyChange,
}: {
initial: PublicSettings;
onSaved?: (next: PublicSettings) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [rows, setRows] = useState<HeaderNavItem[]>(() => rowsOf(initial.header_nav));
const [on, setOn] = useState<BuiltinSwitches>(() => switchesOf(initial.header_nav));
const [savedPayload, setSavedPayload] = useState<HeaderNavItem[]>(() => clone(initial.header_nav));
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
// 新增自定义行挂载后自动聚焦名称输入框
const [focusIdx, setFocusIdx] = useState<number | null>(null);
// 提交负载 = 自定义链接 + 开关打开的内置项,按行顺序
const payload: HeaderNavItem[] = rows.filter((item) => item.type === "custom" || on[item.key]);
const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload);
const atCap = payload.length >= HEADER_NAV_LIMITS.items;
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
useEffect(() => {
if (focusIdx !== null) setFocusIdx(null);
}, [focusIdx]);
const move = (i: number, dir: -1 | 1) => {
setError("");
setRows((prev) => {
const j = i + dir;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
const [row] = next.splice(i, 1);
next.splice(j, 0, row);
return next;
});
};
const removeAt = (i: number) => {
setError("");
setRows((prev) => prev.filter((_, idx) => idx !== i));
};
const addCustom = () => {
if (atCap) return;
setError("");
setFocusIdx(rows.length);
setRows((prev) => [...prev, { type: "custom", label: "", url: "", new_tab: false }]);
};
const updateCustom = (i: number, patch: Partial<Extract<HeaderNavItem, { type: "custom" }>>) => {
setError("");
setRows((prev) => prev.map((item, idx) => (idx === i && item.type === "custom" ? { ...item, ...patch } : item)));
};
const discard = () => {
setRows(rowsOf(savedPayload));
setOn(switchesOf(savedPayload));
setError("");
};
const save = async () => {
if (saving || !dirty) return;
for (const item of payload) {
if (item.type !== "custom") continue;
if (!item.label.trim()) {
setError("自定义菜单名称不能为空");
toast("自定义菜单名称不能为空");
return;
}
if (!footerURLAllowed(item.url)) {
setError(`地址无效:${item.url || "(空)"}`);
toast(`地址无效:${item.url || "(空)"}`);
return;
}
}
const body: HeaderNavItem[] = payload.map((item) =>
item.type === "custom"
? { type: "custom", label: item.label.trim(), url: item.url.trim(), new_tab: item.new_tab }
: { type: "builtin", key: item.key },
);
setSaving(true);
try {
const res = await apiUpdateSiteSettings({ header_nav: body });
setSavedPayload(clone(res.header_nav));
setRows(rowsOf(res.header_nav));
setOn(switchesOf(res.header_nav));
onSaved?.(res);
toast("已保存,顶部导航已更新", "ok");
} catch (e) {
const msg = e instanceof Error ? e.message : "保存失败";
setError(msg);
toast(msg);
} finally {
setSaving(false);
}
};
return (
<AdminSettingsGroup
id="settings-header-nav"
title="顶部导航菜单"
description="桌面端页眉品牌名右侧的菜单,顺序即前台展示顺序;移动端导航由底部菜单承担。全部隐藏后前台不显示导航。"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存"
error={error}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<div className="flex flex-col gap-2">
{rows.map((item, i) => {
const enabled = item.type === "custom" || on[item.key];
return (
<div
key={item.type === "builtin" ? `builtin-${item.key}` : `custom-${i}`}
className="flex flex-wrap items-center gap-2 rounded-xl px-2 py-2"
style={{
border: "1px solid var(--line)",
background: "var(--panel)",
opacity: enabled ? 1 : 0.55,
}}
>
{item.type === "builtin" ? (
<>
<span
className="w-7 h-7 flex items-center justify-center rounded-full shrink-0"
style={{ color: "var(--ink-2)", background: "var(--panel-2)" }}
aria-hidden
>
{BUILTIN_ICONS[item.key]}
</span>
<span className="text-[13.5px] font-semibold shrink-0" style={{ color: "var(--ink)" }}>
{HEADER_NAV_BUILTIN_LABELS[item.key]}
</span>
<span className="meta text-[12px] truncate">{BUILTIN_HINTS[item.key]}</span>
</>
) : (
<>
<div className="admin-field-shell w-32 shrink-0">
<input
className="admin-field-input"
style={{ padding: "7px 10px" }}
maxLength={HEADER_NAV_LIMITS.label}
value={item.label}
onChange={(e) => updateCustom(i, { label: e.target.value })}
placeholder="菜单名称"
aria-label={`第 ${i + 1} 个自定义菜单名称`}
autoFocus={focusIdx === i}
/>
</div>
<div className="admin-field-shell min-w-[10rem] flex-1">
<input
className="admin-field-input"
style={{ padding: "7px 10px" }}
maxLength={HEADER_NAV_LIMITS.url}
value={item.url}
onChange={(e) => {
const v = e.target.value;
const wasExt = isHeaderNavExternal(item.url);
const nowExt = isHeaderNavExternal(v);
updateCustom(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
}}
placeholder="https://… 或 /p/about"
aria-label={`第 ${i + 1} 个自定义菜单地址`}
/>
</div>
<div className="shrink-0 w-[8.75rem]">
<div className="admin-footer-open-toggle" role="group" aria-label={`第 ${i + 1} 个菜单打开方式`}>
<button
type="button"
className={`admin-footer-open-opt${!item.new_tab ? " is-on" : ""}`}
aria-pressed={!item.new_tab}
onClick={() => updateCustom(i, { new_tab: false })}
>
本页
</button>
<button
type="button"
className={`admin-footer-open-opt${item.new_tab ? " is-on" : ""}`}
aria-pressed={item.new_tab}
onClick={() => updateCustom(i, { new_tab: true })}
>
<ExternalLink size={12} aria-hidden />
新标签
</button>
</div>
</div>
</>
)}
<div className="flex items-center gap-1 shrink-0 ml-auto">
<button
type="button"
className="admin-icon-btn"
aria-label="上移"
disabled={i === 0 || saving}
onClick={() => move(i, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="admin-icon-btn"
aria-label="下移"
disabled={i === rows.length - 1 || saving}
onClick={() => move(i, 1)}
>
<ArrowDown size={14} />
</button>
{item.type === "builtin" ? (
<AdminSwitch
checked={on[item.key]}
disabled={saving || (!on[item.key] && atCap)}
onChange={() => {
setError("");
setOn((prev) => ({ ...prev, [item.key]: !prev[item.key] }));
}}
label={`顶部导航显示${HEADER_NAV_BUILTIN_LABELS[item.key]}`}
/>
) : (
<button
type="button"
className="admin-icon-btn is-danger"
aria-label="移除"
onClick={() => removeAt(i)}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
);
})}
<div className="flex flex-wrap items-center gap-2 pt-1">
<button type="button" className="btn btn-line btn-sm" disabled={atCap} onClick={addCustom}>
<Plus size={14} /> 新增自定义链接
<span className="meta text-[12px] ml-1">
{payload.length}/{HEADER_NAV_LIMITS.items}
</span>
</button>
</div>
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
<li>内置菜单(首页/排行榜/书库)用开关控制显隐,地址由系统按伪静态风格自动生成;隐藏项保留排序位置。</li>
<li>自定义链接支持站内路径(如 /p/about、/announcements)或 http(s) 外链;名称最多 16 字。</li>
</ul>
</div>
</AdminSettingsGroup>
);
}