完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,8 +25,9 @@ import {
|
||||
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { formatCommentTime, formatDateTime } from "@/lib/format";
|
||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||
import { useAllowComments, useCommentsRequireLogin } from "@/components/CommentsPolicyProvider";
|
||||
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
||||
import CommentsLoginRequired from "@/components/CommentsLoginRequired";
|
||||
import Avatar from "./Avatar";
|
||||
import Pagination from "./Pagination";
|
||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||
@@ -197,6 +198,7 @@ export default function CommentSection({
|
||||
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
|
||||
const router = useRouter();
|
||||
const commentsOpen = useAllowComments();
|
||||
const commentsRequireLogin = useCommentsRequireLogin();
|
||||
const [content, setContent] = useState("");
|
||||
// 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离
|
||||
const [replyTarget, setReplyTarget] = useState<{
|
||||
@@ -1177,6 +1179,9 @@ export default function CommentSection({
|
||||
if (!commentsOpen) {
|
||||
return <CommentsUnsupported />;
|
||||
}
|
||||
if (commentsRequireLogin && !user) {
|
||||
return <CommentsLoginRequired redirect={`/post/${postId}#comments`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="comments" className="panel p-6 sm:p-8 mt-6 scroll-mt-24" aria-label="评论区">
|
||||
|
||||
30
frontend/components/CommentsLoginRequired.tsx
Normal file
30
frontend/components/CommentsLoginRequired.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { LogIn } from "lucide-react";
|
||||
|
||||
/** 站点开启「登录才可见评论」时,游客看到的评论区占位 */
|
||||
export default function CommentsLoginRequired({ redirect }: { redirect: string }) {
|
||||
const href = `/login?redirect=${encodeURIComponent(redirect)}`;
|
||||
return (
|
||||
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论">
|
||||
<span
|
||||
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--accent) 16%, transparent)",
|
||||
color: "var(--accent)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<LogIn size={22} />
|
||||
</span>
|
||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
登录后查看评论
|
||||
</p>
|
||||
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
||||
本站评论仅对登录用户开放。
|
||||
</p>
|
||||
<Link href={href} className="btn btn-primary btn-sm mt-5 inline-flex">
|
||||
去登录
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,42 +11,68 @@ import {
|
||||
import { useRouter } from "next/navigation";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
|
||||
const AllowCommentsContext = createContext(true);
|
||||
type CommentsPolicy = {
|
||||
open: boolean;
|
||||
requireLogin: boolean;
|
||||
};
|
||||
|
||||
const CommentsPolicyContext = createContext<CommentsPolicy>({
|
||||
open: true,
|
||||
requireLogin: false,
|
||||
});
|
||||
|
||||
/** 前台是否开放评论(SSR 初值 + 后台开关热更新) */
|
||||
export function useAllowComments(): boolean {
|
||||
return useContext(AllowCommentsContext);
|
||||
return useContext(CommentsPolicyContext).open;
|
||||
}
|
||||
|
||||
/** 评论是否仅登录可见 */
|
||||
export function useCommentsRequireLogin(): boolean {
|
||||
return useContext(CommentsPolicyContext).requireLogin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根布局注入:关闭评论后前台立即隐藏评论区/数量;开启则刷新以拉回评论。
|
||||
* 根布局注入:关闭评论后前台立即隐藏评论区/数量;
|
||||
* 「登录可见」切换时刷新以重拉/清空评论树。
|
||||
*/
|
||||
export default function CommentsPolicyProvider({
|
||||
allowComments,
|
||||
requireLogin = false,
|
||||
children,
|
||||
}: {
|
||||
allowComments: boolean;
|
||||
requireLogin?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(allowComments);
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const [policy, setPolicy] = useState<CommentsPolicy>({
|
||||
open: allowComments,
|
||||
requireLogin,
|
||||
});
|
||||
const policyRef = useRef(policy);
|
||||
policyRef.current = policy;
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(allowComments);
|
||||
}, [allowComments]);
|
||||
setPolicy({ open: allowComments, requireLogin });
|
||||
}, [allowComments, requireLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||
if (typeof data?.allow_comments !== "boolean") return;
|
||||
if (openRef.current === data.allow_comments) return;
|
||||
setOpen(data.allow_comments);
|
||||
if (!data) return;
|
||||
const prev = policyRef.current;
|
||||
const nextOpen =
|
||||
typeof data.allow_comments === "boolean" ? data.allow_comments : prev.open;
|
||||
const nextRequire =
|
||||
typeof data.comments_require_login === "boolean"
|
||||
? data.comments_require_login
|
||||
: prev.requireLogin;
|
||||
if (nextOpen === prev.open && nextRequire === prev.requireLogin) return;
|
||||
setPolicy({ open: nextOpen, requireLogin: nextRequire });
|
||||
router.refresh();
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<AllowCommentsContext.Provider value={open}>{children}</AllowCommentsContext.Provider>
|
||||
<CommentsPolicyContext.Provider value={policy}>{children}</CommentsPolicyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import pkg from "../package.json";
|
||||
import type { FooterLink } from "@/lib/brand";
|
||||
import type { FooterLink, FooterLinksAlign } from "@/lib/brand";
|
||||
import FooterTiming from "./FooterTiming";
|
||||
|
||||
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
|
||||
@@ -10,7 +10,29 @@ function realVersion(pkgVersion: string): string {
|
||||
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
|
||||
}
|
||||
|
||||
// 极简页脚:左侧版权 / 版本 / 耗时,右侧备案等链接
|
||||
function zoneLinks(links: FooterLink[], align: FooterLinksAlign): FooterLink[] {
|
||||
return links.filter((item) => (item.align || "right") === align);
|
||||
}
|
||||
|
||||
function LinkGroup({ items }: { items: FooterLink[] }) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="site-footer-links">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={`${item.align}-${item.label}-${item.url}`}
|
||||
href={item.url}
|
||||
className="meta text-[12px] hover:underline"
|
||||
{...(item.new_tab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 极简页脚:版权 / 版本 / 耗时 + 左 / 中 / 右三栏链接
|
||||
export default function Footer({
|
||||
siteName = "姜十三社区",
|
||||
links = [],
|
||||
@@ -20,34 +42,33 @@ export default function Footer({
|
||||
}) {
|
||||
const year = new Date().getFullYear();
|
||||
const brand = siteName.trim() || "姜十三社区";
|
||||
const visibleLinks = links.filter((item) => item.label.trim() && item.url.trim());
|
||||
const visible = links.filter((item) => item.label.trim() && item.url.trim());
|
||||
const left = zoneLinks(visible, "left");
|
||||
const center = zoneLinks(visible, "center");
|
||||
const right = zoneLinks(visible, "right");
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="site-footer"
|
||||
style={{
|
||||
borderTop: "1px solid var(--line)",
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-x-4 gap-y-2 flex-wrap">
|
||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
|
||||
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
||||
<FooterTiming version={realVersion(pkg.version)} />
|
||||
<div className="site-footer-inner max-w-[1440px] mx-auto px-4 sm:px-6 py-4">
|
||||
<div className="site-footer-zone site-footer-zone-left">
|
||||
<div className="site-footer-meta">
|
||||
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
||||
<FooterTiming version={realVersion(pkg.version)} />
|
||||
</div>
|
||||
<LinkGroup items={left} />
|
||||
</div>
|
||||
<div className="site-footer-zone site-footer-zone-center">
|
||||
<LinkGroup items={center} />
|
||||
</div>
|
||||
<div className="site-footer-zone site-footer-zone-right">
|
||||
<LinkGroup items={right} />
|
||||
</div>
|
||||
{visibleLinks.length > 0 ? (
|
||||
<nav aria-label="页脚链接" className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 min-w-0 ml-auto">
|
||||
{visibleLinks.map((item) => (
|
||||
<a
|
||||
key={`${item.label}-${item.url}`}
|
||||
href={item.url}
|
||||
className="meta text-[12px] hover:underline"
|
||||
{...(item.new_tab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -46,7 +46,6 @@ export default function Header({
|
||||
brandMark = "image_text",
|
||||
brandLogoSize = "sq",
|
||||
brandLogoFit = "contain",
|
||||
allowRegister = true,
|
||||
}: {
|
||||
initialUser: User | null;
|
||||
initialUnread?: number;
|
||||
@@ -60,7 +59,6 @@ export default function Header({
|
||||
brandMark?: BrandMark;
|
||||
brandLogoSize?: BrandLogoSize;
|
||||
brandLogoFit?: BrandLogoFit;
|
||||
allowRegister?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -334,11 +332,9 @@ export default function Header({
|
||||
<Link href="/login" className="btn btn-line btn-sm">
|
||||
<LogIn size={14} /> 登录
|
||||
</Link>
|
||||
{allowRegister && (
|
||||
<Link href="/register" className="btn btn-primary btn-sm">
|
||||
<UserPlus size={14} /> 注册
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/register" className="btn btn-primary btn-sm">
|
||||
<UserPlus size={14} /> 注册
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
@@ -402,7 +398,7 @@ export default function Header({
|
||||
</MobileLink>
|
||||
)}
|
||||
{!user && <MobileLink href="/login">登录</MobileLink>}
|
||||
{!user && allowRegister && <MobileLink href="/register">注册</MobileLink>}
|
||||
{!user && <MobileLink href="/register">注册</MobileLink>}
|
||||
</div>
|
||||
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
||||
<ThemeToggle
|
||||
|
||||
17
frontend/components/OperationalBanner.tsx
Normal file
17
frontend/components/OperationalBanner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
import {useEffect,useState} from "react";
|
||||
import {usePathname} from "next/navigation";
|
||||
import {apiOperations} from "@/lib/api";
|
||||
export default function OperationalBanner(){
|
||||
const path=usePathname();
|
||||
const [notice,setNotice]=useState("");
|
||||
useEffect(()=>{
|
||||
let active=true;
|
||||
const refresh=()=>{if(document.visibilityState!=="visible")return; void apiOperations<{maintenance:{mode:string;title:string;message:string}} >("/api/site-state").then(s=>{if(active)setNotice(s.maintenance.mode==="readonly" ? (s.maintenance.message || "站点暂时只读,可以浏览,暂不能提交或修改内容。") : "");}).catch(()=>{});};
|
||||
refresh();const timer=setInterval(refresh,30000);
|
||||
document.addEventListener("visibilitychange",refresh);
|
||||
return ()=>{active=false;clearInterval(timer);document.removeEventListener("visibilitychange",refresh);};
|
||||
},[path]);
|
||||
if(path.startsWith("/admin") || !notice)return null;
|
||||
return <div role="status" className="panel mx-auto my-3 w-full max-w-5xl p-4 text-sm"><strong>只读模式:</strong>{notice}</div>;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
||||
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark } from "@/lib/brand";
|
||||
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
|
||||
|
||||
@@ -52,29 +52,18 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
|
||||
}
|
||||
|
||||
function applyFavicon(url: string) {
|
||||
const existing = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
|
||||
if (!url) {
|
||||
document.getElementById("j13-favicon")?.remove();
|
||||
const hasDefault = [...existing].some((el) => el.id !== "j13-favicon" && el.href.includes("favicon"));
|
||||
if (!hasDefault) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.href = "/favicon.ico";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
return;
|
||||
}
|
||||
existing.forEach((el) => {
|
||||
if (el.id !== "j13-favicon") el.remove();
|
||||
});
|
||||
// 只管理 #j13-favicon,禁止 remove 其它 icon 节点:
|
||||
// Next Metadata 注入的 <link rel="icon"> 由 React 持有,手动删会导致 removeChild 空指针。
|
||||
let link = document.getElementById("j13-favicon") as HTMLLinkElement | null;
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.id = "j13-favicon";
|
||||
link.rel = "icon";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (link.getAttribute("href") !== url) link.href = url;
|
||||
const href = url || "/favicon.ico";
|
||||
if (link.getAttribute("href") !== href) link.href = href;
|
||||
// 挪到 head 末尾,优先于 SSR 注入的 icon
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function retitle(prev: SiteBrand, next: SiteBrand) {
|
||||
@@ -132,7 +121,7 @@ export default function SiteBrandProvider({
|
||||
setBrand((prev) => {
|
||||
if (sameBrand(prev, next)) return prev;
|
||||
retitle(prev, next);
|
||||
applyFavicon(next.favicon_url);
|
||||
if (prev.favicon_url !== next.favicon_url) applyFavicon(next.favicon_url);
|
||||
return next;
|
||||
});
|
||||
}, [initial]);
|
||||
@@ -150,11 +139,13 @@ export default function SiteBrandProvider({
|
||||
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
|
||||
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
|
||||
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
|
||||
footer_links: Array.isArray(raw.footer_links) ? raw.footer_links : prev.footer_links,
|
||||
footer_links: Array.isArray(raw.footer_links)
|
||||
? normalizeFooterLinks(raw.footer_links)
|
||||
: prev.footer_links,
|
||||
};
|
||||
if (sameBrand(prev, next)) return;
|
||||
retitle(prev, next);
|
||||
applyFavicon(next.favicon_url);
|
||||
if (prev.favicon_url !== next.favicon_url) applyFavicon(next.favicon_url);
|
||||
brandRef.current = next;
|
||||
setBrand(next);
|
||||
};
|
||||
|
||||
@@ -40,7 +40,6 @@ export default function SiteChrome({
|
||||
unread,
|
||||
chatUnread,
|
||||
theme,
|
||||
allowRegister,
|
||||
children,
|
||||
}: {
|
||||
initialIsAdmin: boolean;
|
||||
@@ -48,7 +47,6 @@ export default function SiteChrome({
|
||||
unread: number;
|
||||
chatUnread: number;
|
||||
theme: "light" | "dark";
|
||||
allowRegister: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
@@ -103,7 +101,6 @@ export default function SiteChrome({
|
||||
brandMark={brand.brand_mark}
|
||||
brandLogoSize={brand.brand_logo_size}
|
||||
brandLogoFit={brand.brand_logo_fit}
|
||||
allowRegister={allowRegister}
|
||||
/>
|
||||
<main
|
||||
id="main"
|
||||
|
||||
@@ -95,7 +95,7 @@ export function AdminSettingsRow({
|
||||
<div className="admin-settings-row-control">
|
||||
{children}
|
||||
{hintBelow != null && hintBelow !== "" ? (
|
||||
<p className="admin-settings-row-hint is-below">{hintBelow}</p>
|
||||
<div className="admin-settings-row-hint is-below">{hintBelow}</div>
|
||||
) : null}
|
||||
{error != null && error !== "" ? (
|
||||
<p className="admin-settings-row-error" id={errorId} role="alert">
|
||||
|
||||
Reference in New Issue
Block a user