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

299 lines
11 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 { ArrowDown, ArrowUp, ExternalLink, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import {
BRAND_LIMITS,
FOOTER_LINKS_ALIGNS,
footerURLAllowed,
isExternalFooterURL,
type FooterLink,
type FooterLinksAlign,
} from "@/lib/brand";
import { toast } from "@/lib/toast";
function clone(links: FooterLink[]): FooterLink[] {
return links.map((l) => ({ ...l }));
}
function zoneOf(links: FooterLink[], align: FooterLinksAlign): FooterLink[] {
return links.filter((l) => (l.align || "right") === align && l.label.trim() && l.url.trim());
}
export default function FooterLinksPanel({
initial,
onSaved,
onDirtyChange,
}: {
initial: PublicSettings;
onSaved?: (next: PublicSettings) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [links, setLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
const [savedLinks, setSavedLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const saveGen = useRef(0);
const dirty = JSON.stringify(links) !== JSON.stringify(savedLinks);
const left = zoneOf(links, "left");
const center = zoneOf(links, "center");
const right = zoneOf(links, "right");
const hasPreview = left.length + center.length + right.length > 0;
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const update = (i: number, patch: Partial<FooterLink>) => {
setError("");
setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item)));
};
const discard = () => {
setLinks(clone(savedLinks));
setError("");
};
const save = async () => {
if (saving || !dirty) return;
for (const item of links) {
if (!item.label.trim()) {
setError("链接文案不能为空");
toast("链接文案不能为空");
return;
}
if (!footerURLAllowed(item.url)) {
setError(`地址无效:${item.url || "(空)"}`);
toast(`地址无效:${item.url || "(空)"}`);
return;
}
}
setError("");
const snapLinks = clone(links).map((item) => ({
label: item.label.trim(),
url: item.url.trim(),
new_tab: item.new_tab,
align: item.align || "right",
}));
const ticket = ++saveGen.current;
setSaving(true);
try {
const res = await apiUpdateSiteSettings({
footer_links: snapLinks,
});
if (ticket !== saveGen.current) return;
setSavedLinks(clone(res.footer_links));
setLinks((prev) => (JSON.stringify(prev) === JSON.stringify(links) ? clone(res.footer_links) : prev));
onSaved?.(res);
toast("已保存", "ok");
} catch (e) {
if (ticket !== saveGen.current) return;
const msg = e instanceof Error ? e.message : "保存失败";
setError(msg);
toast(msg);
} finally {
if (ticket === saveGen.current) setSaving(false);
}
};
const move = (i: number, dir: -1 | 1) => {
setLinks((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 renderPreviewLinks = (items: FooterLink[]) =>
items.map((item) => (
<span key={`${item.align}-${item.label}-${item.url}`} className="admin-footer-preview-link">
{item.label.trim()}
{item.new_tab ? <ExternalLink size={11} aria-hidden /> : null}
</span>
));
return (
<AdminSettingsGroup
id="settings-footer"
title="页脚链接"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存"
error={error}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<div className="admin-footer-studio">
<div className="admin-footer-preview" aria-label="页脚预览">
<div className="admin-footer-preview-zone admin-footer-preview-zone-left">
<span className="admin-footer-preview-copy">
© {new Date().getFullYear()} {initial.site_name}
</span>
{left.length > 0 ? <nav className="admin-footer-preview-links">{renderPreviewLinks(left)}</nav> : null}
</div>
<div className="admin-footer-preview-zone admin-footer-preview-zone-center">
{center.length > 0 ? (
<nav className="admin-footer-preview-links">{renderPreviewLinks(center)}</nav>
) : null}
</div>
<div className="admin-footer-preview-zone admin-footer-preview-zone-right">
{right.length > 0 ? (
<nav className="admin-footer-preview-links">{renderPreviewLinks(right)}</nav>
) : !hasPreview ? (
<span className="admin-footer-preview-empty meta text-[12px]">暂无链接</span>
) : null}
</div>
</div>
<div className="admin-footer-table" role="table" aria-label="页脚链接列表">
<div className="admin-footer-table-head" role="row">
<span className="admin-footer-col-idx" aria-hidden>
#
</span>
<span>文案</span>
<span>地址</span>
<span className="admin-footer-col-align">位置</span>
<span className="admin-footer-col-open">打开方式</span>
<span className="admin-footer-col-ops">
<span className="sr-only">操作</span>
</span>
</div>
{links.length === 0 ? (
<p className="admin-footer-empty">暂无链接,可添加备案号、用户协议等</p>
) : (
links.map((item, i) => (
<div key={i} className="admin-footer-table-row" role="row">
<span className="admin-footer-col-idx meta">{i + 1}</span>
<div className="admin-field-shell admin-footer-cell-label">
<input
id={`footer-label-${i}`}
className="admin-field-input"
maxLength={BRAND_LIMITS.footerLabel}
value={item.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="如:京 ICP 备…"
aria-label={`第 ${i + 1} 条文案`}
/>
</div>
<div className="admin-field-shell admin-footer-cell-url">
<input
id={`footer-url-${i}`}
className="admin-field-input"
maxLength={BRAND_LIMITS.footerURL}
value={item.url}
onChange={(e) => {
const v = e.target.value;
const wasExt = isExternalFooterURL(item.url);
const nowExt = isExternalFooterURL(v);
update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
}}
placeholder="https://… 或 /about"
aria-label={`第 ${i + 1} 条地址`}
/>
</div>
<div className="admin-footer-col-align">
<div
className="admin-footer-align-toggle"
role="group"
aria-label={`第 ${i + 1} 条位置`}
>
{FOOTER_LINKS_ALIGNS.map((opt) => (
<button
key={opt.id}
type="button"
className={`admin-footer-align-opt${(item.align || "right") === opt.id ? " is-on" : ""}`}
aria-pressed={(item.align || "right") === opt.id}
onClick={() => update(i, { align: opt.id })}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="admin-footer-col-open">
<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={() => update(i, { new_tab: false })}
>
本页
</button>
<button
type="button"
className={`admin-footer-open-opt${item.new_tab ? " is-on" : ""}`}
aria-pressed={item.new_tab}
onClick={() => update(i, { new_tab: true })}
>
<ExternalLink size={12} aria-hidden />
新标签
</button>
</div>
</div>
<div className="admin-footer-col-ops">
<button
type="button"
className="admin-icon-btn"
aria-label="上移"
disabled={i === 0}
onClick={() => move(i, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="admin-icon-btn"
aria-label="下移"
disabled={i === links.length - 1}
onClick={() => move(i, 1)}
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="admin-icon-btn"
aria-label="删除"
onClick={() => setLinks((prev) => prev.filter((_, idx) => idx !== i))}
>
<Trash2 size={14} />
</button>
</div>
</div>
))
)}
</div>
<button
type="button"
className="btn btn-line btn-sm self-start"
disabled={links.length >= BRAND_LIMITS.footerLinks}
onClick={() =>
setLinks((prev) => [...prev, { label: "", url: "", new_tab: false, align: "right" }])
}
>
<Plus size={14} /> 新增链接
<span className="meta text-[12px] ml-1">
{links.length}/{BRAND_LIMITS.footerLinks}
</span>
</button>
</div>
</AdminSettingsGroup>
);
}