公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -38,7 +38,7 @@ export default function Avatar({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
alt={name.trim() || "用户头像"}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
|
||||
@@ -327,7 +327,12 @@ export default function AvatarCropModal({
|
||||
className="absolute inset-0 rounded-full overflow-hidden bg-[var(--panel-2)] transition-transform hover:scale-105 disabled:cursor-default disabled:hover:scale-100"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.url} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.kind === "avatar" ? "历史头像" : "我的图片"}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* hover 指示:成品头像=直接使用,其他图片=裁剪;选用中显示 spinner */}
|
||||
|
||||
@@ -45,18 +45,18 @@ export default function BrandLockup({
|
||||
{src ? (
|
||||
both ? (
|
||||
<>
|
||||
<Logo src={logoLight} fit={fit} extra="j13-brand-logo-light" />
|
||||
<Logo src={logoDark} fit={fit} extra="j13-brand-logo-dark" />
|
||||
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" />
|
||||
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" />
|
||||
</>
|
||||
) : (
|
||||
<Logo src={src} fit={fit} />
|
||||
<Logo src={src} fit={fit} alt={label} decorative={showText} />
|
||||
)
|
||||
) : (
|
||||
<Bubble h={box.h} />
|
||||
<Bubble h={box.h} label={showText ? undefined : label} />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<Bubble h={36} />
|
||||
<Bubble h={36} label={showText ? undefined : label} />
|
||||
)
|
||||
) : null}
|
||||
{showText ? (
|
||||
@@ -75,10 +75,26 @@ export default function BrandLockup({
|
||||
);
|
||||
}
|
||||
|
||||
function Logo({ src, fit, extra = "" }: { src: string; fit: BrandLogoFit; extra?: string }) {
|
||||
function Logo({
|
||||
src,
|
||||
fit,
|
||||
alt,
|
||||
decorative = false,
|
||||
extra = "",
|
||||
}: {
|
||||
src: string;
|
||||
fit: BrandLogoFit;
|
||||
alt: string;
|
||||
/** 旁边已有站名时,读屏跳过,HTML 仍保留 alt 供检索 */
|
||||
decorative?: boolean;
|
||||
extra?: string;
|
||||
}) {
|
||||
if (fit === "repeat") {
|
||||
return (
|
||||
<span
|
||||
role={decorative ? undefined : "img"}
|
||||
aria-hidden={decorative || undefined}
|
||||
aria-label={decorative ? undefined : alt}
|
||||
className={`j13-brand-logo j13-brand-logo-tile ${extra}`}
|
||||
style={{ backgroundImage: `url("${src.replace(/["\\\n\r]/g, "")}")` }}
|
||||
/>
|
||||
@@ -86,17 +102,20 @@ function Logo({ src, fit, extra = "" }: { src: string; fit: BrandLogoFit; extra?
|
||||
}
|
||||
return (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={src} alt="" className={`j13-brand-logo ${extra}`} />
|
||||
<img src={src} alt={alt} aria-hidden={decorative || undefined} className={`j13-brand-logo ${extra}`} />
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ h }: { h: number }) {
|
||||
function Bubble({ h, label }: { h: number; label?: string }) {
|
||||
return (
|
||||
<span
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
className="j13-brand-bubble rounded-2xl flex items-center justify-center shrink-0 transition-transform duration-300 group-hover:rotate-[-6deg]"
|
||||
style={{ width: h, height: h, background: "var(--accent)", color: "var(--accent-on)" }}
|
||||
>
|
||||
<svg width="50%" height="50%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg aria-hidden="true" focusable="false" width="50%" height="50%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
@@ -429,7 +429,7 @@ export default function CommentSection({
|
||||
|
||||
const timeMeta = (c: CommentNode) => (
|
||||
<span className="meta text-[11.5px] inline-flex items-center gap-1.5 flex-wrap">
|
||||
<span>{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
<time dateTime={c.created_at}>{new Date(c.created_at).toLocaleString("zh-CN")}</time>
|
||||
{c.edited && <span title="此评论曾被编辑">已编辑</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ function realVersion(pkgVersion: string): string {
|
||||
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
|
||||
}
|
||||
|
||||
// 极简页脚:版权 + 合规链接 + 实时性能指标
|
||||
// 极简页脚:左侧版权 / 版本 / 耗时,右侧备案等链接
|
||||
export default function Footer({
|
||||
siteName = "姜十三社区",
|
||||
links = [],
|
||||
@@ -20,6 +20,7 @@ export default function Footer({
|
||||
}) {
|
||||
const year = new Date().getFullYear();
|
||||
const brand = siteName.trim() || "姜十三社区";
|
||||
const visibleLinks = links.filter((item) => item.label.trim() && item.url.trim());
|
||||
|
||||
return (
|
||||
<footer
|
||||
@@ -29,10 +30,13 @@ export default function Footer({
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
||||
{links.length > 0 ? (
|
||||
<nav aria-label="页脚链接" className="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
||||
{links.map((item) => (
|
||||
<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>
|
||||
{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}
|
||||
@@ -44,7 +48,6 @@ export default function Footer({
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
<FooterTiming version={realVersion(pkg.version)} />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -110,8 +110,12 @@ export default function FooterTiming({ version = "" }: { version?: string }) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<span className="text-[12px] meta whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-2 text-[12px] tabular-nums whitespace-nowrap" style={{ color: "var(--ink-3)" }}>
|
||||
版本:{version || PLACEHOLDER}
|
||||
<span aria-hidden="true">·</span>
|
||||
页面:{PLACEHOLDER}
|
||||
<span aria-hidden="true">·</span>
|
||||
模板:{PLACEHOLDER}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
12
frontend/components/JsonLd.tsx
Normal file
12
frontend/components/JsonLd.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { serializeJsonLd } from "@/lib/jsonLd";
|
||||
|
||||
/** 服务端输出单个 application/ld+json,不在客户端补脚本 */
|
||||
export default function JsonLd({ data }: { data: Record<string, unknown> | null }) {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,10 @@ export default function Pagination({ total, page, size, basePath, query = {}, on
|
||||
|
||||
const buildHref = (p: number) => {
|
||||
const params = new URLSearchParams(query);
|
||||
params.set("page", String(p));
|
||||
return `${basePath}?${params.toString()}`;
|
||||
if (p > 1) params.set("page", String(p));
|
||||
else params.delete("page");
|
||||
const qs = params.toString();
|
||||
return qs ? `${basePath}?${qs}` : basePath;
|
||||
};
|
||||
|
||||
const handleClick = (p: number, e: React.MouseEvent) => {
|
||||
|
||||
@@ -75,9 +75,9 @@ export default async function PostRow({
|
||||
className="stretch-link min-w-0 shrink"
|
||||
aria-label={post.title}
|
||||
>
|
||||
<h3 className="text-[15px] font-normal leading-snug truncate text-[var(--ink)] transition-colors group-hover:text-[var(--accent)]">
|
||||
<h2 className="text-[15px] font-normal leading-snug truncate text-[var(--ink)] transition-colors group-hover:text-[var(--accent)]">
|
||||
{post.title}
|
||||
</h3>
|
||||
</h2>
|
||||
</Link>
|
||||
{/* 其余徽章紧跟标题右侧 */}
|
||||
{post.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
||||
|
||||
@@ -15,6 +15,9 @@ export default function AdminField({
|
||||
disabled,
|
||||
className = "",
|
||||
minHeight,
|
||||
embed,
|
||||
error,
|
||||
describedBy,
|
||||
}: {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
@@ -29,27 +32,40 @@ export default function AdminField({
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
minHeight?: string;
|
||||
/** 标签由外层设置行提供,本组件只保留计数与输入 */
|
||||
embed?: boolean;
|
||||
error?: string;
|
||||
describedBy?: string;
|
||||
}) {
|
||||
const handle = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
onChange(e.target.value);
|
||||
|
||||
const invalid = Boolean(error);
|
||||
const described = [describedBy, error ? `${id}-error` : null].filter(Boolean).join(" ") || undefined;
|
||||
|
||||
const count =
|
||||
maxLength != null ? (
|
||||
<span className={`admin-field-count${embed ? " is-infield" : ""}`} aria-hidden>
|
||||
{value.length}/{maxLength}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="admin-field-head">
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{maxLength != null && (
|
||||
<span className="admin-field-count" aria-hidden>
|
||||
{value.length}/{maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hint != null && hint !== "" && (
|
||||
{embed ? null : (
|
||||
<div className="admin-field-head">
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{count}
|
||||
</div>
|
||||
)}
|
||||
{!embed && hint != null && hint !== "" && (
|
||||
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">{hint}</p>
|
||||
)}
|
||||
{as === "textarea" ? (
|
||||
<div className="admin-field-shell is-area">
|
||||
<div className={`admin-field-shell is-area${embed && count ? " has-count" : ""}${invalid ? " is-invalid" : ""}`}>
|
||||
{embed ? count : null}
|
||||
<textarea
|
||||
id={id}
|
||||
className="admin-field-input"
|
||||
@@ -59,10 +75,12 @@ export default function AdminField({
|
||||
onChange={handle}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={described}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-field-shell">
|
||||
<div className={`admin-field-shell${embed && count ? " has-count" : ""}${invalid ? " is-invalid" : ""}`}>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
@@ -73,9 +91,17 @@ export default function AdminField({
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={described}
|
||||
/>
|
||||
{embed ? count : null}
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<p className="admin-settings-row-error" id={`${id}-error`} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
160
frontend/components/admin/AdminSettingsLayout.tsx
Normal file
160
frontend/components/admin/AdminSettingsLayout.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** 站点设置工作区:限宽、主内容区内左对齐,不写入后台公共主容器 */
|
||||
export function AdminSettingsPage({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={`admin-settings-page${className ? ` ${className}` : ""}`}>{children}</div>;
|
||||
}
|
||||
|
||||
/** 分类导航 + 主表单列 */
|
||||
export function AdminSettingsWork({
|
||||
nav,
|
||||
children,
|
||||
}: {
|
||||
nav: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-settings-work">
|
||||
<div className="admin-settings-nav-col">{nav}</div>
|
||||
<div className="admin-settings-main">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一组相关设置:标题、说明、字段、底部操作,减少逐项套卡片 */
|
||||
export function AdminSettingsGroup({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
id,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`admin-settings-group${className ? ` ${className}` : ""}`} id={id}>
|
||||
<header className="admin-settings-group-head">
|
||||
<h2 className="admin-settings-group-title">{title}</h2>
|
||||
{description != null && description !== "" ? (
|
||||
<p className="admin-settings-group-desc">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="admin-settings-group-body">{children}</div>
|
||||
{footer != null ? <div className="admin-settings-group-footer">{footer}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 标签列 + 控件列;窄表单自动改为上下堆叠 */
|
||||
export function AdminSettingsRow({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
hintBelow,
|
||||
error,
|
||||
errorId,
|
||||
children,
|
||||
wide,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
htmlFor?: string;
|
||||
/** 短说明,紧邻标签 */
|
||||
hint?: ReactNode;
|
||||
/** 长说明,放在控件下方 */
|
||||
hintBelow?: ReactNode;
|
||||
error?: ReactNode;
|
||||
errorId?: string;
|
||||
children: ReactNode;
|
||||
/** 简介等较长控件 */
|
||||
wide?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const Tag = htmlFor ? "label" : "div";
|
||||
return (
|
||||
<div className={`admin-settings-row${wide ? " is-wide" : ""}${className ? ` ${className}` : ""}`}>
|
||||
<div className="admin-settings-row-label">
|
||||
<Tag className="admin-settings-row-name" {...(htmlFor ? { htmlFor } : {})}>
|
||||
{label}
|
||||
</Tag>
|
||||
{hint != null && hint !== "" ? <p className="admin-settings-row-hint">{hint}</p> : null}
|
||||
</div>
|
||||
<div className="admin-settings-row-control">
|
||||
{children}
|
||||
{hintBelow != null && hintBelow !== "" ? (
|
||||
<p className="admin-settings-row-hint is-below">{hintBelow}</p>
|
||||
) : null}
|
||||
{error != null && error !== "" ? (
|
||||
<p className="admin-settings-row-error" id={errorId} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存单元底部操作:按钮与状态贴齐控件列左边缘 */
|
||||
export function AdminSettingsActions({
|
||||
dirty,
|
||||
saving,
|
||||
saveLabel,
|
||||
onSave,
|
||||
onDiscard,
|
||||
saveDisabled,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
saving?: boolean;
|
||||
saveLabel: string;
|
||||
onSave: () => void;
|
||||
onDiscard?: () => void;
|
||||
saveDisabled?: boolean;
|
||||
error?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const status = saving ? "正在保存…" : error ? error : dirty ? "有未保存的修改" : "已保存";
|
||||
return (
|
||||
<div className="admin-settings-actions" aria-busy={saving || undefined}>
|
||||
<div className="admin-settings-actions-inner">
|
||||
<div className="admin-settings-actions-btns">
|
||||
{children}
|
||||
{onDiscard ? (
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={onDiscard}>
|
||||
撤销修改
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saveDisabled ?? (!dirty || saving)}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className={`admin-settings-actions-status${error ? " is-error" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,13 @@ export { default as AdminPageHeader } from "./AdminPageHeader";
|
||||
export { default as AdminSection } from "./AdminSection";
|
||||
export { default as AdminSwitch } from "./AdminSwitch";
|
||||
export { default as AdminSettingRow } from "./AdminSettingRow";
|
||||
export {
|
||||
AdminSettingsPage,
|
||||
AdminSettingsWork,
|
||||
AdminSettingsGroup,
|
||||
AdminSettingsRow,
|
||||
AdminSettingsActions,
|
||||
} from "./AdminSettingsLayout";
|
||||
export { default as AdminSegmented } from "./AdminSegmented";
|
||||
export type { AdminSegmentOption } from "./AdminSegmented";
|
||||
export { default as AdminSearchField } from "./AdminSearchField";
|
||||
|
||||
@@ -10,11 +10,12 @@ export type FeedSort = "latest" | "new" | "recommended";
|
||||
|
||||
const TAB_KEYS: FeedSort[] = ["latest", "new", "recommended"];
|
||||
|
||||
// 在指定板块内切换排序时保留 ?board=
|
||||
function tabHref(key: FeedSort, boardId: number) {
|
||||
// 在指定板块内切换排序时保留 ?board=;默认排序不写入 URL
|
||||
function tabHref(key: FeedSort, boardId: number, allowComments: boolean) {
|
||||
const params = new URLSearchParams();
|
||||
if (boardId > 0) params.set("board", String(boardId));
|
||||
if (key !== "latest") params.set("sort", key);
|
||||
const def = allowComments ? "latest" : "new";
|
||||
if (key !== def) params.set("sort", key);
|
||||
const qs = params.toString();
|
||||
return qs ? `/?${qs}` : "/";
|
||||
}
|
||||
@@ -31,7 +32,7 @@ export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort;
|
||||
e.preventDefault();
|
||||
const next = (i + (e.key === "ArrowRight" ? 1 : keys.length - 1)) % keys.length;
|
||||
tabRefs.current[next]?.focus();
|
||||
router.push(tabHref(keys[next], boardId));
|
||||
router.push(tabHref(keys[next], boardId, allowComments));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -44,7 +45,7 @@ export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort;
|
||||
ref={(el) => {
|
||||
tabRefs.current[i] = el;
|
||||
}}
|
||||
href={tabHref(key, boardId)}
|
||||
href={tabHref(key, boardId, allowComments)}
|
||||
role="tab"
|
||||
className="j13-tab inline-flex items-center gap-1"
|
||||
aria-selected={current === key}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { PenSquare, Home, LayoutGrid } from "lucide-react";
|
||||
import { PenSquare, Home, LayoutGrid, ShieldCheck } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
|
||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||
function boardHref(id: number) {
|
||||
@@ -11,9 +12,10 @@ function boardHref(id: number) {
|
||||
}
|
||||
|
||||
// 桌面左栏:整块模块,与右栏 j13-module 对齐
|
||||
export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User | null }) {
|
||||
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const showAdmin = !!user && isStaff(user.role);
|
||||
|
||||
return (
|
||||
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
|
||||
@@ -68,6 +70,21 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAdmin ? (
|
||||
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<Link href="/admin" className="j13-bcard">
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ShieldCheck size={15} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -376,7 +376,7 @@ export default function ImageLightbox({
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={current.src}
|
||||
alt={current.alt || ""}
|
||||
alt={current.alt?.trim() || "图片"}
|
||||
className={`md-lightbox-img${oddRot ? " is-odd-rot" : ""}${snap ? " is-snap" : ""}`}
|
||||
draggable={false}
|
||||
style={{
|
||||
|
||||
@@ -13,14 +13,14 @@ function collectGallery(from: HTMLImageElement): { items: LightboxItem[]; index:
|
||||
const items: LightboxItem[] = nodes
|
||||
.map((el) => ({
|
||||
src: el.currentSrc || el.getAttribute("src") || "",
|
||||
alt: el.getAttribute("alt") || undefined,
|
||||
alt: el.dataset.caption || undefined,
|
||||
}))
|
||||
.filter((it) => !!it.src);
|
||||
const selfSrc = from.currentSrc || from.getAttribute("src") || "";
|
||||
let index = items.findIndex((it) => it.src === selfSrc);
|
||||
if (index < 0) index = 0;
|
||||
if (items.length === 0 && selfSrc) {
|
||||
return { items: [{ src: selfSrc, alt: from.alt || undefined }], index: 0 };
|
||||
return { items: [{ src: selfSrc, alt: from.dataset.caption || undefined }], index: 0 };
|
||||
}
|
||||
return { items, index };
|
||||
}
|
||||
@@ -39,10 +39,13 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
|
||||
setLightbox(gallery);
|
||||
}, []);
|
||||
|
||||
const provided = (typeof alt === "string" ? alt.trim() : "") || (typeof title === "string" ? title.trim() : "");
|
||||
const imgAlt = provided || "图片";
|
||||
|
||||
if (!src || broken) {
|
||||
return (
|
||||
<span className="md-img-fallback" role="img" aria-label={alt || "图片加载失败"}>
|
||||
{alt || "图片加载失败"}
|
||||
<span className="md-img-fallback" role="img" aria-label={provided || "图片加载失败"}>
|
||||
{provided || "图片加载失败"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +56,8 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
|
||||
<img
|
||||
{...rest}
|
||||
src={src}
|
||||
alt={alt || ""}
|
||||
alt={imgAlt}
|
||||
data-caption={provided || undefined}
|
||||
title={title}
|
||||
className={`md-content-img${className ? ` ${className}` : ""}`}
|
||||
loading="lazy"
|
||||
|
||||
Reference in New Issue
Block a user