299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|