feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2, CircleHelp, Coins } from "lucide-react";
|
||||
import type { AcceptedAnswer } from "@/lib/api";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import MarkdownBody from "@/components/MarkdownBody";
|
||||
|
||||
@@ -24,6 +26,8 @@ export default async function AcceptedAnswerCard({
|
||||
const answerLabel = isQuestion ? "最佳答案" : "采纳答案";
|
||||
const endTime = solvedAt || answer.created_at;
|
||||
const endLabel = formatEndsAt(endTime);
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
const authorHref = author ? userPath(urlStyle, author.id) : "";
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -51,12 +55,12 @@ export default async function AcceptedAnswerCard({
|
||||
</span>
|
||||
{author ? (
|
||||
<>
|
||||
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={name}>
|
||||
<Link href={authorHref} className="shrink-0" aria-label={name}>
|
||||
<Avatar name={name} src={author.avatar || undefined} size={32} />
|
||||
</Link>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<Link
|
||||
href={`/u/${author.id}`}
|
||||
href={authorHref}
|
||||
className="truncate text-[13.5px] font-semibold hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
|
||||
@@ -11,6 +11,8 @@ import LevelBadge from "@/components/LevelBadge";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import ImageLightbox from "@/components/markdown/ImageLightbox";
|
||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
@@ -36,6 +38,7 @@ export default function ChatUserCard({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const allowMessages = useAllowMessages();
|
||||
const urlStyle = useUrlStyle();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [dmBusy, setDmBusy] = useState(false);
|
||||
const [avatarOpen, setAvatarOpen] = useState(false);
|
||||
@@ -194,7 +197,7 @@ export default function ChatUserCard({
|
||||
|
||||
{/* 操作区 */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Link href={`/u/${user.id}`} className="btn btn-line btn-sm flex-1 justify-center">
|
||||
<Link href={userPath(urlStyle, user.id)} className="btn btn-line btn-sm flex-1 justify-center">
|
||||
用户主页
|
||||
</Link>
|
||||
{allowMessages && !isSelf && (
|
||||
|
||||
@@ -41,6 +41,8 @@ import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/Dele
|
||||
import EditHistoryModal from "@/components/EditHistoryModal";
|
||||
import { scrollFlashTo } from "@/lib/flash";
|
||||
import { fetchCommentLocation } from "@/lib/api";
|
||||
import { postPath, userPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
|
||||
interface CommentSectionProps {
|
||||
postId: string;
|
||||
@@ -215,6 +217,7 @@ export default function CommentSection({
|
||||
}: CommentSectionProps) {
|
||||
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
|
||||
const router = useRouter();
|
||||
const urlStyle = useUrlStyle();
|
||||
const commentsOpen = useAllowComments();
|
||||
const commentsRequireLogin = useCommentsRequireLogin();
|
||||
const staffViewer = !!user && isStaff(user.role);
|
||||
@@ -329,7 +332,7 @@ export default function CommentSection({
|
||||
if (cancelled || floor <= 0) return;
|
||||
const destPage = Math.max(1, Math.ceil(floor / size));
|
||||
if (destPage === page) return; // 同页却无节点(异常数据),放弃
|
||||
router.replace(`/post/${postId}?page=${destPage}#comment-${id}`, { scroll: false });
|
||||
router.replace(`${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${id}`, { scroll: false });
|
||||
} catch {
|
||||
/* 定位失败静默:停留在当前页 */
|
||||
}
|
||||
@@ -359,15 +362,15 @@ export default function CommentSection({
|
||||
if (destPage !== page) {
|
||||
setAwayHref(
|
||||
destPage > 1
|
||||
? `/post/${postId}?page=${destPage}#comment-${res.comment.id}`
|
||||
: `/post/${postId}#comment-${res.comment.id}`
|
||||
? `${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${res.comment.id}`
|
||||
: `${postPath(urlStyle, Number(postId))}#comment-${res.comment.id}`
|
||||
);
|
||||
} else {
|
||||
setAwayHref(null);
|
||||
}
|
||||
router.refresh();
|
||||
} else if (res.error === "未登录") {
|
||||
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
|
||||
router.push(`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(postId)))}`);
|
||||
} else {
|
||||
const msg = res.error || "评论失败";
|
||||
setFloorError(msg);
|
||||
@@ -415,7 +418,7 @@ export default function CommentSection({
|
||||
router.refresh();
|
||||
focusCommentControl(target.id, "reply");
|
||||
} else if (res.error === "未登录") {
|
||||
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
|
||||
router.push(`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(postId)))}`);
|
||||
} else {
|
||||
const msg = res.error || "回复失败";
|
||||
setReplyError(msg);
|
||||
@@ -929,7 +932,7 @@ export default function CommentSection({
|
||||
<MosaicAvatar size={isFloor ? 34 : 32} />
|
||||
) : author ? (
|
||||
<Link
|
||||
href={`/u/${author.id}`}
|
||||
href={userPath(urlStyle, author.id)}
|
||||
className={isFloor ? "shrink-0 mt-0.5" : "shrink-0"}
|
||||
aria-label={displayName}
|
||||
>
|
||||
@@ -942,7 +945,7 @@ export default function CommentSection({
|
||||
const nameRow = showMosaic ? (
|
||||
<MosaicName />
|
||||
) : author ? (
|
||||
<Link href={`/u/${author.id}`} className="group">
|
||||
<Link href={userPath(urlStyle, author.id)} className="group">
|
||||
<span
|
||||
className={`truncate group-hover:text-[var(--accent)] transition-colors ${
|
||||
isFloor ? "text-sm" : "cmt-reply-name"
|
||||
@@ -980,7 +983,7 @@ export default function CommentSection({
|
||||
{c.staff_deleted && deleter ? (
|
||||
<div className="cmt-tombstone-mod">
|
||||
<span>处理人</span>
|
||||
<Link href={`/u/${deleter.id}`} aria-label={`查看管理员 ${deleterName}`}>
|
||||
<Link href={userPath(urlStyle, deleter.id)} aria-label={`查看管理员 ${deleterName}`}>
|
||||
<Avatar name={deleterName} src={deleter.avatar || undefined} size={20} />
|
||||
{deleterName}
|
||||
</Link>
|
||||
@@ -1088,7 +1091,7 @@ export default function CommentSection({
|
||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
|
||||
<div className="flex gap-3">
|
||||
{author ? (
|
||||
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={authorName}>
|
||||
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
|
||||
<Avatar name={authorName} src={author.avatar || undefined} size={32} />
|
||||
</Link>
|
||||
) : (
|
||||
@@ -1098,7 +1101,7 @@ export default function CommentSection({
|
||||
<div className="cmt-meta">
|
||||
<div className="cmt-meta-lead">
|
||||
{author ? (
|
||||
<Link href={`/u/${author.id}`} className="cmt-name-link group">
|
||||
<Link href={userPath(urlStyle, author.id)} className="cmt-name-link group">
|
||||
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
|
||||
{authorName}
|
||||
</span>
|
||||
@@ -1189,7 +1192,7 @@ export default function CommentSection({
|
||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-item scroll-mt-24">
|
||||
<div className="flex gap-3">
|
||||
{author ? (
|
||||
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={authorName}>
|
||||
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
|
||||
<Avatar name={authorName} src={author.avatar || undefined} size={34} />
|
||||
</Link>
|
||||
) : (
|
||||
@@ -1199,7 +1202,7 @@ export default function CommentSection({
|
||||
<div className="cmt-meta">
|
||||
<div className="cmt-meta-lead">
|
||||
{author ? (
|
||||
<Link href={`/u/${author.id}`} className="cmt-name-link group">
|
||||
<Link href={userPath(urlStyle, author.id)} className="cmt-name-link group">
|
||||
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
|
||||
{authorName}
|
||||
</span>
|
||||
@@ -1302,7 +1305,7 @@ export default function CommentSection({
|
||||
return <CommentsUnsupported />;
|
||||
}
|
||||
if (commentsRequireLogin && !user) {
|
||||
return <CommentsLoginRequired redirect={`/post/${postId}#comments`} />;
|
||||
return <CommentsLoginRequired redirect={`${postPath(urlStyle, Number(postId))}#comments`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1386,7 +1389,7 @@ export default function CommentSection({
|
||||
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
|
||||
登录后参与讨论
|
||||
</span>
|
||||
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
|
||||
<Link href={`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(postId)))}`} className="btn btn-line btn-sm">
|
||||
去登录
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1405,7 +1408,7 @@ export default function CommentSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
|
||||
<Pagination total={total} page={page} size={size} basePath={postPath(urlStyle, Number(postId))} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={rejectTargetId != null}
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -265,6 +267,7 @@ export default function EditHistoryModal({
|
||||
fetchPage: FetchHistoryPage;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const urlStyle = useUrlStyle();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -366,12 +369,12 @@ export default function EditHistoryModal({
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<Link href={`/u/${it.editor.id}`} className="shrink-0">
|
||||
<Link href={userPath(urlStyle, it.editor.id)} className="shrink-0">
|
||||
<Avatar name={name} src={it.editor.avatar || undefined} size={28} />
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/u/${it.editor.id}`}
|
||||
href={userPath(urlStyle, it.editor.id)}
|
||||
className="text-[13px] hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
||||
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
|
||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||
@@ -33,6 +34,8 @@ import LevelBadge from "./LevelBadge";
|
||||
import BadgeChip from "./BadgeChip";
|
||||
import BrandLockup from "./BrandLockup";
|
||||
import { useAllowMessages } from "./MessagesPolicyProvider";
|
||||
import { useUrlStyle } from "./SiteBrandProvider";
|
||||
import { leaderboardPath } from "@/lib/urlStyle";
|
||||
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
|
||||
|
||||
export default function Header({
|
||||
@@ -62,6 +65,8 @@ export default function Header({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const urlStyle = useUrlStyle();
|
||||
const lbHref = leaderboardPath(urlStyle);
|
||||
const allowMessages = useAllowMessages();
|
||||
const hideComposeNav =
|
||||
pathname === "/compose" ||
|
||||
@@ -239,8 +244,8 @@ export default function Header({
|
||||
首页
|
||||
</NavPill>
|
||||
<NavPill
|
||||
href="/leaderboard"
|
||||
active={pathname?.startsWith("/leaderboard") ?? false}
|
||||
href={lbHref}
|
||||
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
|
||||
icon={<Trophy size={15} />}
|
||||
>
|
||||
排行榜
|
||||
@@ -410,6 +415,7 @@ function NavPill({
|
||||
* 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像
|
||||
*/
|
||||
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
const urlStyle = useUrlStyle();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -493,7 +499,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
<Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心
|
||||
</Link>
|
||||
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<Link href={userPath(urlStyle, user.id)} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
|
||||
</Link>
|
||||
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
|
||||
@@ -40,6 +40,8 @@ import ImageInsertModal from "@/components/ImageInsertModal";
|
||||
import TimelineEditor from "@/components/TimelineEditor";
|
||||
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
|
||||
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
|
||||
import { postPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
|
||||
type MarkdownEditorProps = {
|
||||
value: string;
|
||||
@@ -157,6 +159,7 @@ export default function MarkdownEditor({
|
||||
onUploadingChange,
|
||||
}: MarkdownEditorProps) {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const urlStyle = useUrlStyle();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [mode, setMode] = useState<EditorMode>("write");
|
||||
@@ -472,7 +475,7 @@ export default function MarkdownEditor({
|
||||
|
||||
/** 选中站内帖子:回填 URL 与文字,用户可再编辑后点「插入」 */
|
||||
const pickPostLink = (p: PostListItem) => {
|
||||
setLinkUrl(`/post/${p.id}`);
|
||||
setLinkUrl(postPath(urlStyle, p.id));
|
||||
setLinkText(p.title);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import { apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import { parseEntityPath, userPath, leaderboardPath } from "@/lib/urlStyle";
|
||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||
|
||||
@@ -30,6 +32,8 @@ export default function MobileTabBar({
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const allowMessages = useAllowMessages();
|
||||
const urlStyle = useUrlStyle();
|
||||
const lbHref = leaderboardPath(urlStyle);
|
||||
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
||||
|
||||
// 登录态经 router.refresh() 下发新 props 时校正徽标
|
||||
@@ -96,10 +100,10 @@ export default function MobileTabBar({
|
||||
active={isActive((p) => p === "/")}
|
||||
/>
|
||||
<TabLink
|
||||
href="/leaderboard"
|
||||
href={lbHref}
|
||||
icon={<Trophy size={20} />}
|
||||
label="排行榜"
|
||||
active={isActive((p) => p.startsWith("/leaderboard"))}
|
||||
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
|
||||
/>
|
||||
<TabLink
|
||||
href={user ? "/compose" : "/login"}
|
||||
@@ -118,11 +122,18 @@ export default function MobileTabBar({
|
||||
/>
|
||||
)}
|
||||
<TabLink
|
||||
href={user ? `/u/${user.id}` : "/login"}
|
||||
href={user ? userPath(urlStyle, user.id) : "/login"}
|
||||
icon={<UserIcon size={20} />}
|
||||
label="我的"
|
||||
active={
|
||||
!!user && isActive((p) => p === `/u/${user.id}` || p.startsWith(`/u/${user.id}/`))
|
||||
!!user &&
|
||||
isActive((p) => {
|
||||
const parsed = parseEntityPath(p);
|
||||
return (
|
||||
(parsed?.kind === "user" && parsed.id === user.id) ||
|
||||
p.startsWith(`${userPath(urlStyle, user.id)}/`)
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,12 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { postPath, userPath } from "@/lib/urlStyle";
|
||||
|
||||
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
|
||||
const router = useRouter();
|
||||
const urlStyle = useUrlStyle();
|
||||
const [open, setOpen] = useState(false);
|
||||
// SSR 直出未读数,红点不再挂载后才弹出;WS/回前台校准
|
||||
const [unread, setUnread] = useState(initialUnread);
|
||||
@@ -103,16 +106,16 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
if (n.type === "mention" && n.room_id) {
|
||||
router.push(`/chat/${n.room_id}`);
|
||||
} else if (n.type === "pending_review") {
|
||||
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
|
||||
router.push(n.post_id ? postPath(urlStyle, n.post_id) : "/admin/content?status=pending");
|
||||
} else if (n.type === "badge") {
|
||||
return;
|
||||
} else if (n.type === "follow") {
|
||||
router.push(`/u/${n.actor_id}`);
|
||||
router.push(userPath(urlStyle, n.actor_id));
|
||||
} else if (n.comment_id > 0) {
|
||||
// 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
|
||||
router.push(`/post/${n.post_id}#comment-${n.comment_id}`);
|
||||
router.push(`${postPath(urlStyle, n.post_id)}#comment-${n.comment_id}`);
|
||||
} else {
|
||||
router.push(`/post/${n.post_id}`);
|
||||
router.push(postPath(urlStyle, n.post_id));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { Pin } from "lucide-react";
|
||||
import type { PostListItem } from "@/lib/api";
|
||||
import { postPath } from "@/lib/urlStyle";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import Avatar from "./Avatar";
|
||||
import { IconComment, IconEye } from "./Icons";
|
||||
|
||||
@@ -15,7 +17,7 @@ function formatTime(dateStr: string) {
|
||||
* 管理员置顶区:独立于普通帖子列表。
|
||||
* 陶土色语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。
|
||||
*/
|
||||
export default function PinnedPosts({
|
||||
export default async function PinnedPosts({
|
||||
posts,
|
||||
showBoard = true,
|
||||
}: {
|
||||
@@ -23,6 +25,7 @@ export default function PinnedPosts({
|
||||
showBoard?: boolean;
|
||||
}) {
|
||||
if (posts.length === 0) return null;
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
|
||||
return (
|
||||
<section className="mb-6" aria-label="置顶讨论">
|
||||
@@ -43,7 +46,7 @@ export default function PinnedPosts({
|
||||
{posts.map((p, i) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/post/${p.id}`}
|
||||
href={postPath(urlStyle, p.id)}
|
||||
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)] j13-fade-up"
|
||||
style={{
|
||||
...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}),
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { fileExtBadge, fileKind } from "@/lib/fileKind";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Modal from "@/components/Modal";
|
||||
import { postPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
|
||||
function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
@@ -86,7 +88,8 @@ export default function PostAttachments({
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const loginHref = `/login?redirect=${encodeURIComponent(`/post/${postId}`)}`;
|
||||
const urlStyle = useUrlStyle();
|
||||
const loginHref = `/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`;
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [pending, setPending] = useState<PostAttachment | null>(null);
|
||||
const [unlockedIds, setUnlockedIds] = useState<number[]>([]);
|
||||
|
||||
@@ -35,6 +35,8 @@ import DeadlinePicker, {
|
||||
} from "@/components/DeadlinePicker";
|
||||
import Modal from "@/components/Modal";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import { postPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
|
||||
const TITLE_MAX = 256;
|
||||
/** 帖子正文字符上限(与后端 handler/post.go 校验一致) */
|
||||
@@ -179,6 +181,7 @@ export type PostComposerProps = CreateProps | EditProps;
|
||||
|
||||
export default function PostComposer(props: PostComposerProps) {
|
||||
const router = useRouter();
|
||||
const urlStyle = useUrlStyle();
|
||||
const isCreate = props.mode === "create";
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
@@ -828,7 +831,7 @@ export default function PostComposer(props: PostComposerProps) {
|
||||
} else {
|
||||
toast("发布成功", "ok");
|
||||
}
|
||||
router.push(`/post/${res.post.id}`);
|
||||
router.push(postPath(urlStyle, res.post.id));
|
||||
} else {
|
||||
const mapped = mapPostError(res.error || "发帖失败");
|
||||
reportSubmitError(
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||
import { postPath, userPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
|
||||
export default function PostInteractPanels({
|
||||
postId,
|
||||
@@ -286,6 +288,7 @@ function PollPanel({
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const urlStyle = useUrlStyle();
|
||||
const [sel, setSel] = useState<number[]>(state.my_options || []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const showResults = state.closed || (state.my_options && state.my_options.length > 0);
|
||||
@@ -418,7 +421,7 @@ function PollPanel({
|
||||
<ul className="j13-poll-voters">
|
||||
{namedVoters.map((v) => (
|
||||
<li key={v.id}>
|
||||
<Link href={`/u/${v.id}`} className="j13-poll-voter">
|
||||
<Link href={userPath(urlStyle, v.id)} className="j13-poll-voter">
|
||||
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
|
||||
{v.nickname || v.username}
|
||||
</Link>
|
||||
@@ -445,7 +448,7 @@ function PollPanel({
|
||||
)}
|
||||
{!loggedIn && !state.closed && (
|
||||
<Link
|
||||
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
|
||||
href={`/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`}
|
||||
className="btn btn-line btn-sm"
|
||||
>
|
||||
登录后投票
|
||||
@@ -559,6 +562,7 @@ function LotteryPanel({
|
||||
state: LotteryState;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const urlStyle = useUrlStyle();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => {
|
||||
@@ -651,7 +655,7 @@ function LotteryPanel({
|
||||
className="j13-lottery-winner-avatar"
|
||||
/>
|
||||
<div className="j13-lottery-winner-body">
|
||||
<Link href={`/u/${w.id}`} className="j13-lottery-winner-name">
|
||||
<Link href={userPath(urlStyle, w.id)} className="j13-lottery-winner-name">
|
||||
{w.nickname || w.username}
|
||||
</Link>
|
||||
{w.prize_name ? (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowLeft } from "lucide-react";
|
||||
import type { PostListItem } from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { postPath, userPath } from "@/lib/urlStyle";
|
||||
import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
||||
import LikeButton from "./LikeButton";
|
||||
import Avatar from "./Avatar";
|
||||
@@ -32,7 +33,9 @@ export default async function PostRow({
|
||||
showViews?: boolean;
|
||||
showLike?: boolean;
|
||||
}) {
|
||||
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
|
||||
const settings = await getPublicSettingsCached();
|
||||
const allowComments = settings.allow_comments !== false;
|
||||
const urlStyle = settings.url_style;
|
||||
const lastReply = post.last_reply;
|
||||
const replied =
|
||||
allowComments &&
|
||||
@@ -56,7 +59,7 @@ export default async function PostRow({
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/u/${post.user.id}`}
|
||||
href={userPath(urlStyle, post.user.id)}
|
||||
className="shrink-0 above-link"
|
||||
aria-label={authorName}
|
||||
tabIndex={-1}
|
||||
@@ -71,7 +74,7 @@ export default async function PostRow({
|
||||
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
|
||||
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
href={postPath(urlStyle, post.id)}
|
||||
className="stretch-link min-w-0 shrink"
|
||||
aria-label={post.title}
|
||||
>
|
||||
@@ -98,7 +101,7 @@ export default async function PostRow({
|
||||
<span className="j13-board-tag">{post.board.name}</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/u/${post.user.id}`}
|
||||
href={userPath(urlStyle, post.user.id)}
|
||||
className="above-link font-medium hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
{authorName}
|
||||
@@ -109,7 +112,7 @@ export default async function PostRow({
|
||||
<span className="inline-flex items-center gap-1 min-w-0">
|
||||
<ArrowLeft size={13} className="shrink-0" aria-hidden />
|
||||
<Link
|
||||
href={`/u/${lastReply!.user.id}`}
|
||||
href={userPath(urlStyle, lastReply!.user.id)}
|
||||
className="above-link font-medium hover:text-[var(--accent)] transition-colors truncate max-w-[8em]"
|
||||
>
|
||||
{lastReply!.user.nickname || lastReply!.user.username}
|
||||
@@ -128,7 +131,7 @@ export default async function PostRow({
|
||||
<div className="flex items-center gap-3 sm:gap-4 shrink-0">
|
||||
{allowComments ? (
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
href={postPath(urlStyle, post.id)}
|
||||
tabIndex={-1}
|
||||
aria-label={`${post.comment_count} 条评论`}
|
||||
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} above-link items-center gap-1.5 meta tabular-nums text-[13.5px] hover:text-[var(--accent)] transition-colors`}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
@@ -407,6 +409,7 @@ function IdentityHeader({
|
||||
displayName: string;
|
||||
currentMetaLabel: string;
|
||||
}) {
|
||||
const urlStyle = useUrlStyle();
|
||||
const metaBits: string[] = [`ID ${target.id}`];
|
||||
if (typeof target.points === "number") {
|
||||
metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`);
|
||||
@@ -472,7 +475,7 @@ function IdentityHeader({
|
||||
<span className="sr-only">当前角色 {currentMetaLabel}</span>
|
||||
</p>
|
||||
<Link
|
||||
href={`/u/${target.id}`}
|
||||
href={userPath(urlStyle, target.id)}
|
||||
className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
||||
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
|
||||
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
|
||||
|
||||
@@ -17,11 +18,12 @@ export type SiteBrand = {
|
||||
brand_logo_size: PublicSettings["brand_logo_size"];
|
||||
brand_logo_fit: PublicSettings["brand_logo_fit"];
|
||||
footer_links: PublicSettings["footer_links"];
|
||||
url_style: UrlStyle;
|
||||
};
|
||||
|
||||
const SiteBrandContext = createContext<SiteBrand | null>(null);
|
||||
|
||||
function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand {
|
||||
function fromSettings(s: PublicSettings): SiteBrand {
|
||||
return {
|
||||
site_name: s.site_name,
|
||||
site_wordmark: s.site_wordmark,
|
||||
@@ -33,6 +35,7 @@ function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand {
|
||||
brand_logo_size: s.brand_logo_size,
|
||||
brand_logo_fit: s.brand_logo_fit,
|
||||
footer_links: s.footer_links,
|
||||
url_style: s.url_style,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +50,7 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
|
||||
a.brand_mark === b.brand_mark &&
|
||||
a.brand_logo_size === b.brand_logo_size &&
|
||||
a.brand_logo_fit === b.brand_logo_fit &&
|
||||
a.url_style === b.url_style &&
|
||||
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
|
||||
);
|
||||
}
|
||||
@@ -97,11 +101,17 @@ export function useSiteBrand(): SiteBrand {
|
||||
brand_logo_size: "sq",
|
||||
brand_logo_fit: "contain",
|
||||
footer_links: [],
|
||||
url_style: "default",
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** 详情页伪静态风格(SSR 初值与水合一致,设置保存后 realtime 热更) */
|
||||
export function useUrlStyle(): UrlStyle {
|
||||
return useSiteBrand().url_style;
|
||||
}
|
||||
|
||||
/** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */
|
||||
export default function SiteBrandProvider({
|
||||
initial,
|
||||
@@ -142,6 +152,7 @@ export default function SiteBrandProvider({
|
||||
footer_links: Array.isArray(raw.footer_links)
|
||||
? normalizeFooterLinks(raw.footer_links)
|
||||
: prev.footer_links,
|
||||
url_style: raw.url_style !== undefined ? normalizeUrlStyle(raw.url_style) : prev.url_style,
|
||||
};
|
||||
if (sameBrand(prev, next)) return;
|
||||
retitle(prev, next);
|
||||
|
||||
@@ -48,6 +48,8 @@ import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import NotificationBell from "@/components/NotificationBell";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
@@ -294,6 +296,7 @@ function AdminRailNav({
|
||||
}
|
||||
|
||||
function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
const urlStyle = useUrlStyle();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -375,7 +378,7 @@ function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void })
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5">
|
||||
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<Link href={userPath(urlStyle, user.id)} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
|
||||
</Link>
|
||||
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
|
||||
@@ -24,10 +24,13 @@ import LevelBadge from "@/components/LevelBadge";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import { PulseCheckin } from "./CheckinCard";
|
||||
import { formatCompact } from "@/lib/format";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { postPath, userPath } from "@/lib/urlStyle";
|
||||
|
||||
/* ---------- 1. 板块管理员 ---------- */
|
||||
|
||||
export function Moderators({ items }: { items: BoardModerator[] }) {
|
||||
export async function Moderators({ items }: { items: BoardModerator[] }) {
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
return (
|
||||
<section className="j13-module" aria-label="板块管理员">
|
||||
<p className="j13-module-title mb-2">
|
||||
@@ -41,7 +44,7 @@ export function Moderators({ items }: { items: BoardModerator[] }) {
|
||||
const name = u.nickname || u.username;
|
||||
return (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
|
||||
<Link href={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
|
||||
<Avatar name={name} src={u.avatar || undefined} size={30} />
|
||||
<span className="min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{name}
|
||||
@@ -163,7 +166,8 @@ export function BoardPulse({
|
||||
|
||||
/* ---------- 4. 本板热门 ---------- */
|
||||
|
||||
export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
export async function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
return (
|
||||
<section className="j13-module" aria-label="本板热门">
|
||||
<p className="j13-module-title mb-1.5">
|
||||
@@ -175,7 +179,7 @@ export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allo
|
||||
<ol className="j13-ranklist">
|
||||
{posts.map((p, i) => (
|
||||
<li key={p.id}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}>
|
||||
<Link href={postPath(urlStyle, p.id)} className="j13-rank" data-tip={p.title}>
|
||||
<span className="j13-rank-no">{i + 1}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="j13-rank-title truncate">
|
||||
@@ -203,7 +207,8 @@ export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allo
|
||||
|
||||
/* ---------- 5. 本板活跃 ---------- */
|
||||
|
||||
export function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
export async function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
return (
|
||||
<section className="j13-module" aria-label="本板活跃成员">
|
||||
<p className="j13-module-title mb-2">
|
||||
@@ -223,7 +228,7 @@ export function BoardActive({ users, allowComments }: { users: ActiveUser[]; all
|
||||
const name = u.nickname || u.username;
|
||||
return (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
|
||||
<Link href={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
|
||||
<Avatar name={name} src={u.avatar || undefined} size={30} />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span
|
||||
|
||||
@@ -29,6 +29,8 @@ import type {
|
||||
} from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { AdText } from "@/components/AdText";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { announcementPath, postPath, userPath, pagePath } from "@/lib/urlStyle";
|
||||
import { PulseCheckin } from "./CheckinCard";
|
||||
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
||||
import { formatCompact, formatShortDate } from "@/lib/format";
|
||||
@@ -136,7 +138,7 @@ export function PulseCard({
|
||||
|
||||
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
|
||||
|
||||
export function AboutSite({
|
||||
export async function AboutSite({
|
||||
pages,
|
||||
siteDescription,
|
||||
canManagePages,
|
||||
@@ -147,6 +149,7 @@ export function AboutSite({
|
||||
}) {
|
||||
if (pages.length === 0 && !canManagePages) return null;
|
||||
const lead = siteDescription?.trim() ?? "";
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
|
||||
return (
|
||||
<section className="j13-module j13-about" aria-label="关于本站">
|
||||
@@ -160,7 +163,7 @@ export function AboutSite({
|
||||
const Icon = pageGlyph(p.slug);
|
||||
return (
|
||||
<li key={p.id}>
|
||||
<Link href={`/p/${p.slug}`} className="j13-about-item">
|
||||
<Link href={pagePath(urlStyle, p.slug)} className="j13-about-item">
|
||||
<span className="j13-about-ico" aria-hidden="true">
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
@@ -181,13 +184,14 @@ export function AboutSite({
|
||||
|
||||
/* ---------- 站点公告 ---------- */
|
||||
|
||||
export function Announcements({
|
||||
export async function Announcements({
|
||||
items,
|
||||
canManageAnnouncements,
|
||||
}: {
|
||||
items: AnnouncementSummary[];
|
||||
canManageAnnouncements?: boolean;
|
||||
}) {
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
return (
|
||||
<section className="j13-module" aria-label="站点公告">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -218,7 +222,7 @@ export function Announcements({
|
||||
{items.map((a) => (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/announcement/${a.id}`}
|
||||
href={announcementPath(urlStyle, a.id)}
|
||||
className="j13-anno-item"
|
||||
data-tip={a.title}
|
||||
>
|
||||
@@ -254,7 +258,8 @@ export function Announcements({
|
||||
|
||||
/* ---------- 热门榜 ---------- */
|
||||
|
||||
export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
export async function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
return (
|
||||
<section className="j13-module" aria-label="热门讨论">
|
||||
<p className="j13-module-title mb-1.5">
|
||||
@@ -266,7 +271,7 @@ export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; al
|
||||
<ol className="j13-ranklist">
|
||||
{posts.map((p, i) => (
|
||||
<li key={p.id}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}>
|
||||
<Link href={postPath(urlStyle, p.id)} className="j13-rank" data-tip={p.title}>
|
||||
<span className="j13-rank-no">{i + 1}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="j13-rank-title truncate">
|
||||
@@ -294,8 +299,9 @@ export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; al
|
||||
|
||||
/* ---------- 最新注册 ---------- */
|
||||
|
||||
export function NewUsers({ users }: { users: NewUser[] }) {
|
||||
export async function NewUsers({ users }: { users: NewUser[] }) {
|
||||
if (users.length === 0) return null;
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
return (
|
||||
<section className="j13-module" aria-label="最新注册">
|
||||
<p className="j13-module-title mb-3">
|
||||
@@ -307,7 +313,7 @@ export function NewUsers({ users }: { users: NewUser[] }) {
|
||||
return (
|
||||
<li key={u.id}>
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
href={userPath(urlStyle, u.id)}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
|
||||
>
|
||||
<Avatar name={name} src={u.avatar} size={38} />
|
||||
@@ -329,7 +335,8 @@ export function NewUsers({ users }: { users: NewUser[] }) {
|
||||
|
||||
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
|
||||
|
||||
export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
export async function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
return (
|
||||
<section className="j13-module" aria-label="本周活跃成员">
|
||||
<p className="j13-module-title mb-2">
|
||||
@@ -346,7 +353,7 @@ export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; all
|
||||
const name = u.nickname || u.username;
|
||||
return (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
|
||||
<Link href={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
|
||||
<Avatar name={name} src={u.avatar} size={30} />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Bookmark, Home, ShieldCheck } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
|
||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||
function boardHref(id: number) {
|
||||
@@ -23,6 +25,7 @@ export function WorkspaceNavPanel({
|
||||
user?: User | null;
|
||||
}) {
|
||||
const params = useSearchParams();
|
||||
const urlStyle = useUrlStyle();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const showAdmin = !!user && isStaff(user.role);
|
||||
|
||||
@@ -46,7 +49,7 @@ export function WorkspaceNavPanel({
|
||||
</Link>
|
||||
|
||||
{user ? (
|
||||
<Link href={`/u/${user.id}?tab=favorites`} className="j13-bcard">
|
||||
<Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard">
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { apiUnlockPost, apiUnlockPostPassword } from "@/lib/api";
|
||||
import { CSRF_COOKIE } from "@/lib/cookies";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { hideKindLabel, type HideKind } from "@/lib/hideBlocks";
|
||||
import { postPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
|
||||
/** 若浏览器尚无 CSRF cookie,请求公开接口以触发后端签发 */
|
||||
async function ensureGuestCSRF() {
|
||||
@@ -44,6 +46,7 @@ export default function MdHideBlock({
|
||||
children,
|
||||
}: MdHideBlockProps) {
|
||||
const router = useRouter();
|
||||
const urlStyle = useUrlStyle();
|
||||
const pwdErrId = useId();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pwd, setPwd] = useState("");
|
||||
@@ -51,7 +54,9 @@ export default function MdHideBlock({
|
||||
const label = hideKindLabel(kind);
|
||||
const loginTo =
|
||||
loginHref ||
|
||||
(postId ? `/login?redirect=${encodeURIComponent(`/post/${postId}`)}` : "/login");
|
||||
(postId
|
||||
? `/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`
|
||||
: "/login");
|
||||
|
||||
if (locked) {
|
||||
const unlockPoints = async () => {
|
||||
@@ -145,7 +150,7 @@ export default function MdHideBlock({
|
||||
<Link
|
||||
href={
|
||||
postId
|
||||
? `/login?redirect=${encodeURIComponent(`/post/${postId}#comment-input`)}`
|
||||
? `/login?redirect=${encodeURIComponent(`${postPath(urlStyle, postId)}#comment-input`)}`
|
||||
: loginTo
|
||||
}
|
||||
className="btn btn-primary"
|
||||
|
||||
@@ -6,6 +6,8 @@ import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import LevelBadge from "@/components/LevelBadge";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { postPath, userPath } from "@/lib/urlStyle";
|
||||
|
||||
// formatYMD 帖子详情页左栏「作者其他帖」日期
|
||||
function formatYMD(dateStr: string) {
|
||||
@@ -54,7 +56,7 @@ export function BoardCard({ board }: { board: Board }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthorRailCard({
|
||||
export async function AuthorRailCard({
|
||||
profile,
|
||||
allowComments,
|
||||
boardNames,
|
||||
@@ -66,6 +68,7 @@ export function AuthorRailCard({
|
||||
boardNames?: Record<number, string>;
|
||||
boardColors?: Record<number, number>;
|
||||
}) {
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
const u = profile.user;
|
||||
const signature = (u.signature || "").trim();
|
||||
const { post_count, comment_count, points, streak } = profile.stats;
|
||||
@@ -80,13 +83,13 @@ export function AuthorRailCard({
|
||||
<User size={15} style={{ color: "var(--accent)" }} /> 作者
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
|
||||
<Link href={userPath(urlStyle, u.id)} className="shrink-0" aria-label={u.nickname}>
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
href={userPath(urlStyle, u.id)}
|
||||
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
@@ -160,15 +163,16 @@ export function AuthorRailCard({
|
||||
<Flame size={12.5} /> 连签 {streak} 天
|
||||
</p>
|
||||
)}
|
||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3">
|
||||
<Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm w-full justify-center mt-3">
|
||||
访问主页
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthorPosts({ posts }: { posts: PostListItem[] }) {
|
||||
export async function AuthorPosts({ posts }: { posts: PostListItem[] }) {
|
||||
if (posts.length === 0) return null;
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
return (
|
||||
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
|
||||
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
|
||||
@@ -178,7 +182,7 @@ export function AuthorPosts({ posts }: { posts: PostListItem[] }) {
|
||||
{posts.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={`/post/${p.id}`}
|
||||
href={postPath(urlStyle, p.id)}
|
||||
data-tip={p.title}
|
||||
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
>
|
||||
|
||||
@@ -2,9 +2,11 @@ import Link from "next/link";
|
||||
import { Megaphone, Pin } from "lucide-react";
|
||||
import type { AnnouncementSummary } from "@/lib/api";
|
||||
import { formatShortDate } from "@/lib/format";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { announcementPath } from "@/lib/urlStyle";
|
||||
|
||||
/** 近期公告目录(高亮当前 id) */
|
||||
export default function SiteDocAnnoNav({
|
||||
export default async function SiteDocAnnoNav({
|
||||
items,
|
||||
currentId,
|
||||
showAllLink = true,
|
||||
@@ -13,6 +15,7 @@ export default function SiteDocAnnoNav({
|
||||
currentId?: number;
|
||||
showAllLink?: boolean;
|
||||
}) {
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
return (
|
||||
<section className="j13-module flex min-h-0 flex-col" aria-label="近期公告">
|
||||
<div className="flex items-center justify-between gap-2 shrink-0">
|
||||
@@ -37,7 +40,7 @@ export default function SiteDocAnnoNav({
|
||||
return (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/announcement/${a.id}`}
|
||||
href={announcementPath(urlStyle, a.id)}
|
||||
className="j13-anno-item"
|
||||
data-tip={a.title}
|
||||
aria-current={current ? "page" : undefined}
|
||||
|
||||
@@ -2,10 +2,12 @@ import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { SidebarPageItem } from "@/lib/api";
|
||||
import { pageGlyph } from "@/lib/pageGlyph";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { pagePath } from "@/lib/urlStyle";
|
||||
import AboutSiteHeader from "./AboutSiteHeader";
|
||||
|
||||
/** 侧栏单页兄弟目录(高亮当前 slug);标题栏与首页「关于本站」一致 */
|
||||
export default function SiteDocPageNav({
|
||||
export default async function SiteDocPageNav({
|
||||
pages,
|
||||
currentSlug,
|
||||
title = "文档目录",
|
||||
@@ -17,6 +19,7 @@ export default function SiteDocPageNav({
|
||||
canManage?: boolean;
|
||||
}) {
|
||||
if (pages.length === 0 && !canManage) return null;
|
||||
const urlStyle = (await getPublicSettingsCached()).url_style;
|
||||
|
||||
return (
|
||||
<section className="j13-module j13-about flex min-h-0 flex-col" aria-label={title}>
|
||||
@@ -31,7 +34,7 @@ export default function SiteDocPageNav({
|
||||
return (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={`/p/${p.slug}`}
|
||||
href={pagePath(urlStyle, p.slug)}
|
||||
className="j13-about-item"
|
||||
aria-current={current ? "page" : undefined}
|
||||
style={
|
||||
|
||||
Reference in New Issue
Block a user