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

292 lines
9.0 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 { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
import { toast } from "@/lib/toast";
export default function BasicIdentityPanel({
initial,
onSaved,
onDirtyChange,
}: {
initial: PublicSettings;
onSaved?: (next: PublicSettings) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [name, setName] = useState(initial.site_name);
const [siteURL, setSiteURL] = useState("");
useEffect(() => {
apiOperations<{ site_url: string }>("/api/site-state")
.then((s) => setSiteURL(s.site_url))
.catch(() => {});
}, []);
const [wordmark, setWordmark] = useState(initial.site_wordmark);
const [slogan, setSlogan] = useState(initial.site_slogan);
const [desc, setDesc] = useState(initial.site_description);
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
const [kwInput, setKwInput] = useState("");
const [saved, setSaved] = useState({
name: initial.site_name,
wordmark: initial.site_wordmark,
slogan: initial.site_slogan,
desc: initial.site_description,
keywords: initial.site_keywords,
});
const [saving, setSaving] = useState(false);
const [nameError, setNameError] = useState("");
const [saveError, setSaveError] = useState("");
const saveGen = useRef(0);
const dirty =
name.trim() !== saved.name ||
wordmark.trim() !== saved.wordmark ||
slogan.trim() !== saved.slogan ||
desc.trim() !== saved.desc ||
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const preview = siteDocumentTitle(name, slogan);
const addKeyword = (raw: string) => {
const parts = raw
.split(/[,,;;]/)
.map((s) => s.trim())
.filter(Boolean);
if (parts.length === 0) return;
setSaveError("");
setKeywords((prev) => {
const next = [...prev];
const seen = new Set(next.map((k) => k.toLowerCase()));
for (const k of parts) {
if (runeCount(k) > BRAND_LIMITS.keywordRunes) {
toast(`关键词「${k}」超过 ${BRAND_LIMITS.keywordRunes} 字`);
continue;
}
if (seen.has(k.toLowerCase())) continue;
if (next.length >= BRAND_LIMITS.keywordCount) {
toast(`最多 ${BRAND_LIMITS.keywordCount} 个关键词`);
break;
}
seen.add(k.toLowerCase());
next.push(k);
}
return next;
});
setKwInput("");
};
const discard = () => {
setName(saved.name);
setWordmark(saved.wordmark);
setSlogan(saved.slogan);
setDesc(saved.desc);
setKeywords([...saved.keywords]);
setKwInput("");
setNameError("");
setSaveError("");
};
const save = async () => {
if (saving) return;
const snap = {
name: name.trim(),
wordmark: wordmark.trim(),
slogan: slogan.trim(),
desc: desc.trim(),
keywords: [...keywords],
};
if (!snap.name) {
setNameError("显示名称不能为空");
toast("显示名称不能为空");
return;
}
setNameError("");
setSaveError("");
const ticket = ++saveGen.current;
setSaving(true);
try {
const res = await apiUpdateSiteSettings({
site_name: snap.name,
site_wordmark: snap.wordmark,
site_slogan: snap.slogan,
site_description: snap.desc,
site_keywords: snap.keywords,
});
if (ticket !== saveGen.current) return;
setSaved({
name: res.site_name,
wordmark: res.site_wordmark,
slogan: res.site_slogan,
desc: res.site_description,
keywords: res.site_keywords,
});
setName((v) => (v.trim() === snap.name ? res.site_name : v));
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
setKeywords((prev) =>
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
);
onSaved?.(res);
toast("已保存", "ok");
} catch (e) {
if (ticket !== saveGen.current) return;
const msg = e instanceof Error ? e.message : "保存失败";
setSaveError(msg);
toast(msg);
} finally {
if (ticket === saveGen.current) setSaving(false);
}
};
const clearSaveError = () => {
if (saveError) setSaveError("");
};
return (
<AdminSettingsGroup
id="settings-identity"
title="站点身份"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存"
error={saveError}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<div className="admin-identity-studio">
<div className="admin-identity-url" role="group" aria-label="正式站点地址">
<span className="admin-identity-url-label">正式地址</span>
<code className="admin-identity-url-value">{siteURL || "尚未部署 SITE_URL"}</code>
</div>
<div className="admin-identity-grid">
<AdminField
id="site-name"
label="显示名称"
className="admin-identity-field"
maxLength={32}
value={name}
onChange={(v) => {
setName(v);
if (nameError) setNameError("");
clearSaveError();
}}
placeholder="姜十三论坛"
error={nameError}
/>
<AdminField
id="site-wordmark"
label="字标"
className="admin-identity-field"
maxLength={BRAND_LIMITS.wordmark}
value={wordmark}
onChange={(v) => {
setWordmark(v);
clearSaveError();
}}
placeholder="姜十三"
/>
</div>
<AdminField
id="site-slogan"
label="标语"
className="admin-identity-field"
maxLength={BRAND_LIMITS.slogan}
value={slogan}
onChange={(v) => {
setSlogan(v);
clearSaveError();
}}
placeholder="技术分享与讨论"
/>
<p className="admin-identity-title-preview" aria-live="polite">
浏览器标题预览 · <strong>{preview}</strong>
</p>
<AdminField
id="site-desc"
label="简介"
as="textarea"
className="admin-identity-field"
minHeight="72px"
maxLength={160}
value={desc}
onChange={(v) => {
setDesc(v);
clearSaveError();
}}
placeholder="一句话介绍社区…"
/>
<div className="admin-identity-keywords">
<div className="admin-identity-keywords-head">
<label htmlFor="seo-keyword-input" className="field-label">
关键词
</label>
<span className="meta text-[12px]">
{keywords.length}/{BRAND_LIMITS.keywordCount}
</span>
</div>
<div className="admin-identity-keywords-box">
{keywords.length > 0 ? (
<div className="admin-settings-keywords">
{keywords.map((k) => (
<button
key={k}
type="button"
className="admin-settings-kw"
onClick={() => {
setKeywords((prev) => prev.filter((x) => x !== k));
clearSaveError();
}}
>
{k}
<X size={12} />
</button>
))}
</div>
) : null}
<div className="admin-identity-keywords-add">
<div className="admin-field-shell flex-1 min-w-0">
<input
id="seo-keyword-input"
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "," || e.key === ",") {
e.preventDefault();
addKeyword(kwInput);
}
}}
placeholder="回车添加"
className="admin-field-input"
aria-label="添加关键词"
/>
</div>
<button
type="button"
className="btn btn-line btn-sm shrink-0"
onClick={() => addKeyword(kwInput)}
>
添加
</button>
</div>
</div>
</div>
</div>
</AdminSettingsGroup>
);
}