feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS

站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 05:27:33 +08:00
parent 5f7193042f
commit d784b0ea7a
68 changed files with 2588 additions and 768 deletions

View File

@@ -1,9 +1,12 @@
import pkg from "../package.json";
import FooterTiming from "./FooterTiming";
// 仅把明确发布过的版本视为"真实版本";脚手架默认号视为未设计,传空显示占位
function realVersion(v: string): string {
return !v || v === "0.0.0" || v === "0.1.0" ? "" : v;
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
// 未注入时,脚手架默认号仍当未发布,页脚显示占位。
function realVersion(pkgVersion: string): string {
const injected = process.env.NEXT_PUBLIC_APP_VERSION?.trim();
if (injected) return injected;
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
}
// 极简页脚:版权 + 实时性能指标(版本 / 页面加载 / 模板生成)
@@ -12,7 +15,13 @@ export default function Footer({ siteName = "姜十三社区" }: { siteName?: st
const brand = siteName.trim() || "姜十三社区";
return (
<footer className="mt-14" style={{ borderTop: "1px solid var(--line)" }}>
<footer
className="mt-14"
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-4">
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
<FooterTiming version={realVersion(pkg.version)} />

View File

@@ -199,9 +199,7 @@ export default function Header({
className="sticky top-0 z-50 border-b"
style={{
borderColor: "var(--line)",
background: "color-mix(in srgb, var(--panel) 86%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
background: "var(--panel)",
}}
>
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">

View File

@@ -44,7 +44,7 @@ export default function PinnedPosts({
<Link
key={p.id}
href={`/post/${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(--panel-2)] focus-visible:bg-[var(--panel-2)] j13-fade-up"
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)" } : {}),
animationDelay: `${i * 35}ms`,

View File

@@ -45,7 +45,7 @@ export default async function PostRow({
return (
<div
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--panel-2)] focus-within:bg-[var(--panel-2)] j13-fade-up ${
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--item-hover-bg)] focus-within:bg-[var(--item-hover-bg)] j13-fade-up ${
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
}`}
style={{

View File

@@ -1,11 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { Ban, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import Link from "next/link";
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { apiAdminSetUserBan, apiAdminSetUserRole, type Board } from "@/lib/api";
import { ROLES, ROLE_META } from "@/lib/roles";
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import Modal from "@/components/Modal";
import RoleBadge from "@/components/RoleBadge";
/** 角色与账号弹窗所需的最小用户形状(后台列表项 / 公开资料均可) */
export interface RoleAccountTarget {
@@ -15,6 +19,10 @@ export interface RoleAccountTarget {
role: string;
board_ids?: number[];
banned: boolean;
avatar?: string;
email?: string;
points?: number;
created_at?: string;
}
interface RoleOption {
@@ -125,9 +133,7 @@ export default function RoleAccountModal({
if (willUnban) parts.push("已解封");
toast(parts.join(" · ") || "已保存", "ok");
onSaved({
id: target.id,
username: target.username,
nickname: target.nickname,
...target,
role: nextRole,
board_ids: nextBoards,
banned: nextBanned,
@@ -140,26 +146,20 @@ export default function RoleAccountModal({
};
const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存";
const displayName = target.nickname || target.username;
const currentMeta = roleMeta(target.role);
return (
<Modal
open
title="角色与账号"
description={
<>
<span className="truncate block">
@{target.username}({target.nickname})
</span>
<span className="block mt-1">
角色决定后台入口与前台管理能力;封禁立即强制下线。站长内容档案仍走列表入口。
</span>
</>
}
header={<IdentityHeader target={target} displayName={displayName} currentMetaLabel={currentMeta.label} />}
onClose={onClose}
busy={acting}
maxWidthClass="max-w-[500px]"
panelClassName="p-6 max-h-[88vh] overflow-y-auto"
ariaLabel="角色与账号"
maxWidthClass="max-w-[540px]"
panelClassName="p-6 max-h-[88vh] flex flex-col overflow-hidden"
bodyClassName="overflow-y-auto flex-1 min-h-0 -mx-1 px-1"
ariaLabel={`设置 ${displayName} 的角色与账号`}
footer={
<>
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
@@ -177,7 +177,7 @@ export default function RoleAccountModal({
</>
}
>
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
<p className="mb-2.5 text-[12px] font-medium tracking-wide" style={{ color: "var(--ink-3)" }}>
管理角色
</p>
<div className="flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
@@ -231,7 +231,7 @@ export default function RoleAccountModal({
{role === ROLES.BOARD_ADMIN && (
<div className="mt-4">
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2" style={{ color: "var(--ink-3)" }}>
授权板块 <span style={{ color: "var(--danger)" }}>*</span>
</p>
{boards.length === 0 ? (
@@ -280,7 +280,7 @@ export default function RoleAccountModal({
)}
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
账号状态
</p>
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="账号状态">
@@ -289,17 +289,17 @@ export default function RoleAccountModal({
role="radio"
aria-checked={!banned}
onClick={() => setBanned(false)}
className="rounded-xl px-3 py-2.5 text-left transition-colors"
className="rounded-xl px-3.5 py-3 text-left transition-colors"
style={{
border: `1.5px solid ${!banned ? "var(--ok)" : "var(--line)"}`,
background: !banned ? "var(--ok-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13px] font-medium"
className="flex items-center gap-1.5 text-[13.5px] font-medium"
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
>
<RotateCcw size={14} /> 正常
<RotateCcw size={15} /> 正常
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可登录与互动
@@ -310,17 +310,17 @@ export default function RoleAccountModal({
role="radio"
aria-checked={banned}
onClick={() => setBanned(true)}
className="rounded-xl px-3 py-2.5 text-left transition-colors"
className="rounded-xl px-3.5 py-3 text-left transition-colors"
style={{
border: `1.5px solid ${banned ? "var(--danger)" : "var(--line)"}`,
background: banned ? "var(--danger-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13px] font-medium"
className="flex items-center gap-1.5 text-[13.5px] font-medium"
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
>
<Ban size={14} /> 封禁
<Ban size={15} /> 封禁
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
强制下线,禁止登录
@@ -328,7 +328,7 @@ export default function RoleAccountModal({
</button>
</div>
{willBan && (
<p className="mt-2 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
<p className="mt-2.5 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
</p>
)}
@@ -336,3 +336,89 @@ export default function RoleAccountModal({
</Modal>
);
}
function IdentityHeader({
target,
displayName,
currentMetaLabel,
}: {
target: RoleAccountTarget;
displayName: string;
currentMetaLabel: string;
}) {
const metaBits: string[] = [`ID ${target.id}`];
if (typeof target.points === "number") {
metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`);
}
if (target.created_at) {
metaBits.push(`注册 ${formatDate(target.created_at)}`);
}
return (
<div>
<p
className="text-[11px] font-medium uppercase tracking-[0.14em] mb-3"
style={{ color: "var(--ink-3)" }}
>
角色与账号
</p>
<div
className="flex items-start gap-4 min-w-0 rounded-2xl p-3.5"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
{/* 描边用 box-shadow,勿把实色垫在默认头像后面:.av-* 底是半透明,会透出垫色把字母对比打翻 */}
<span
className="inline-flex items-center justify-center shrink-0 rounded-full"
style={{
boxShadow: target.banned
? "0 0 0 3px var(--panel-2), 0 0 0 4.5px var(--danger)"
: "0 0 0 3px var(--panel-2), 0 0 0 4.5px color-mix(in srgb, var(--accent) 38%, transparent)",
}}
aria-hidden
>
<Avatar
name={displayName}
src={target.avatar || undefined}
size={68}
/>
</span>
<div className="min-w-0 flex-1 pt-0.5">
<p
className="text-[18px] sm:text-[20px] font-extrabold tracking-tight leading-tight truncate"
style={{ color: "var(--ink)" }}
>
{displayName}
</p>
<p className="mt-0.5 text-[13px] truncate" style={{ color: "var(--ink-2)" }}>
@{target.username}
{target.email ? ` · ${target.email}` : ""}
</p>
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
<RoleBadge role={target.role} boardIds={target.board_ids} />
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6]"
style={
target.banned
? { background: "var(--danger-soft)", color: "var(--danger)" }
: { background: "var(--ok-soft)", color: "var(--ok)" }
}
>
{target.banned ? "已封禁" : "正常"}
</span>
</div>
<p className="mt-2 text-[11.5px] tabular-nums leading-relaxed" style={{ color: "var(--ink-3)" }}>
{metaBits.join(" · ")}
<span className="sr-only">当前角色 {currentMetaLabel}</span>
</p>
<Link
href={`/u/${target.id}`}
className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
查看个人主页 <ExternalLink size={12} aria-hidden />
</Link>
</div>
</div>
</div>
);
}

View File

@@ -76,6 +76,10 @@ export default function SiteChrome({
// 首屏跟 SSR;之后在渲染期直接读 pathname,软导航无 Header 闪动
const isAdmin = mounted ? pathIsAdmin(pathname) : initialIsAdmin;
useLayoutEffect(() => {
document.documentElement.dataset.j13Chrome = isAdmin ? "admin" : "site";
}, [isAdmin]);
if (isAdmin) {
return <>{children}</>;
}

View File

@@ -32,6 +32,9 @@ export interface StaffUserTarget {
board_ids?: number[];
/** 有权访客资料接口下发;未知时按未封禁处理 */
banned?: boolean;
avatar?: string;
created_at?: string;
points?: number;
}
/**
@@ -127,6 +130,9 @@ export default function StaffUserMenu({
role: target.role,
board_ids: target.board_ids,
banned,
avatar: target.avatar,
created_at: target.created_at,
points: target.points,
};
const itemCls =

View File

@@ -4,29 +4,33 @@ import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { apiGetSettings } from "@/lib/api";
import { applyAccentTheme } from "@/lib/theme";
import { applyCustomCSS } from "@/lib/customCode";
import {
applySiteBackground,
bgConfigFromSettings,
type SiteBgConfig,
type SiteBgInput,
} from "@/lib/bg";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import {
VISIBLE_RECONCILE_GAP_MS,
onDocumentVisible,
} from "@/lib/visibilityReconcile";
// 站点外观同步器:
// 主题色 / 自定义 CSS 由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换。
// 自定义 JS 只在整页加载时执行,不在此热替换(避免重复绑定)。
// 站点主题色 / 背景图同步器:
// 管理员保存后,其他已打开标签页不会因软导航重跑根布局 SSR。
// WS 推送 + 标签页重新可见时探测公开设置,热替换 accent 与背景。
const MIN_FETCH_GAP_MS = VISIBLE_RECONCILE_GAP_MS;
export default function ThemeSync({
initialAccent,
initialCustomCSS,
initialBg,
}: {
initialAccent: string;
initialCustomCSS: string;
initialBg: SiteBgConfig;
}) {
const pathname = usePathname();
const currentRef = useRef((initialAccent || "").trim().toLowerCase());
const cssRef = useRef((initialCustomCSS || "").trim());
const bgRef = useRef(initialBg);
const lastFetchAtRef = useRef(0);
const inFlightRef = useRef(false);
@@ -42,12 +46,11 @@ export default function ThemeSync({
applyAccentTheme(next || null);
};
const applyCSS = (next: string) => {
const applyBg = (raw: SiteBgInput) => {
if (cancelled) return;
const text = (next || "").trim();
if (text === cssRef.current) return;
cssRef.current = text;
applyCustomCSS(text);
const next = bgConfigFromSettings(raw);
bgRef.current = next;
applySiteBackground(next);
};
const sync = async () => {
@@ -57,10 +60,10 @@ export default function ThemeSync({
lastFetchAtRef.current = now;
inFlightRef.current = true;
try {
const settings = await apiGetSettings();
const s = await apiGetSettings();
if (cancelled) return;
applyAccent((settings.accent || "").trim().toLowerCase());
applyCSS(settings.custom_css || "");
applyAccent((s.accent || "").trim().toLowerCase());
applyBg(s);
} catch {
// 同步失败静默,下次回前台再试
} finally {
@@ -71,7 +74,7 @@ export default function ThemeSync({
const offPush = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (!data) return;
applyAccent((data.accent || "").trim().toLowerCase());
if (typeof data.custom_css === "string") applyCSS(data.custom_css);
applyBg(data);
});
const offVisible = onDocumentVisible(() => void sync(), MIN_FETCH_GAP_MS);

View File

@@ -46,20 +46,27 @@ export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
<p className="text-[12.5px] leading-tight" style={{ color: "var(--ink)" }}>
每日签到
</p>
<p className="meta mt-0.5 truncate text-[11px]">
{guest
? "登录后每日签到领积分"
: checked
? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
{!guest && (
<p className="meta mt-0.5 flex min-w-0 items-center gap-1 text-[11px]">
<span className="min-w-0 truncate">
{guest
? "登录后每日签到领积分"
: checked
? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
</span>
{!guest ? (
<>
{" · "}
<Link href="/points" className="hover:text-[var(--accent)] transition-colors">
<span className="shrink-0" aria-hidden>
·
</span>
<Link
href="/points"
className="j13-checkin-flow shrink-0 rounded-sm px-0.5 -mx-0.5 hover:text-[var(--accent)] transition-colors"
>
流水
</Link>
</>
)}
) : null}
</p>
</div>
{guest ? (

View File

@@ -296,7 +296,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
<li key={u.id}>
<Link
href={`/u/${u.id}`}
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
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} />
<span

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { PenSquare, Home } from "lucide-react";
import { PenSquare, Home, LayoutGrid } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
@@ -10,7 +10,7 @@ function boardHref(id: number) {
return `/?board=${id}`;
}
// 桌面左栏:板块导航
// 桌面左栏:整块模块,与右栏 j13-module 对齐
export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User | null }) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
@@ -18,57 +18,57 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
return (
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
<nav
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] j13-rail-scroll"
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]"
aria-label="工作台导航"
>
<div className="space-y-5">
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示;卡片样式与下方板块一致 */}
<div className="space-y-0.5">
<Link href="/" className="j13-bcard" aria-current={activeBoard === 0 ? "page" : undefined}>
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden">
<p className="j13-module-title shrink-0">
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
板块目录
{boards.length > 0 ? (
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
{boards.length} 个
</span>
) : null}
</p>
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
<Link
href="/"
className="j13-bcard"
aria-current={activeBoard === 0 ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Home size={16} />
</span>
<span className="j13-bcard-name flex-1 truncate text-[14px]">
全部讨论
<Home size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
</Link>
</div>
{/* 板块目录:纯分区标题;下方卡片仅在中栏筛选该板帖子流 */}
<div>
<div className="px-1 mb-2 flex items-center justify-between text-[11.5px] font-medium tracking-[0.08em] uppercase text-[var(--ink-3)]">
<span>板块目录</span>
{boards.length > 0 && (
<span className="tracking-normal tabular-nums">{boards.length} 个</span>
)}
</div>
<div className="space-y-2">
{boards.map((b, i) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-bcard"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
{b.name.slice(0, 1)}
</span>
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
<span className="j13-bcard-name truncate text-[14px]">
{b.name}
</span>
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
</span>
</Link>
))}
</div>
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
{boards.map((b, i) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-bcard"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
{b.name.slice(0, 1)}
</span>
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
<span className="j13-bcard-name truncate">{b.name}</span>
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
</span>
</Link>
))}
</div>
</div>
</section>
</nav>
);
}

View File

@@ -3,11 +3,17 @@ import { ChevronRight } from "lucide-react";
export type Crumb = { href?: string; label: string };
/** 位置指引:桌面叠在中栏卡片上方的内边距里,不拉低卡片、不对齐到左栏 */
export default function SiteDocBreadcrumb({ items }: { items: Crumb[] }) {
/** 位置指引:嵌在正文面板顶部,不单独成块 */
export default function SiteDocBreadcrumb({
items,
className,
}: {
items: Crumb[];
className?: string;
}) {
return (
<nav
className="meta mb-2 flex items-center gap-1 flex-wrap text-[12.5px] leading-none lg:absolute lg:bottom-full lg:left-0 lg:right-0 lg:mb-0 lg:pb-2 lg:pl-1"
className={`j13-page-crumb meta flex items-center gap-1 flex-wrap text-[12.5px] leading-none ${className ?? ""}`}
aria-label="面包屑"
>
{items.map((item, i) => {

View File

@@ -14,7 +14,7 @@ export default function SiteDocShell({
left?: ReactNode;
right?: ReactNode;
children: ReactNode;
/** 中栏卡片上方的位置指引;桌面绝对定位,不占栅格高度 */
/** 中栏正文上方的位置指引(流式排布,不再绝对定位) */
breadcrumb?: ReactNode;
/** 窄屏下沉到正文之后的侧栏副本 */
mobileExtras?: ReactNode;
@@ -29,7 +29,7 @@ export default function SiteDocShell({
{left}
</aside>
<div className="min-w-0 relative">
<div className="min-w-0">
{breadcrumb}
{children}
</div>