新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

@@ -15,8 +15,8 @@ import {
FileCode, PenLine, Maximize2, Minimize2,
} from 'lucide-react';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { renderPostContentHtml } from '../utils/postContent';
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
import PostContent from './PostContent';
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
import {
wrapMarkdownSelection,
@@ -362,7 +362,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
}, [markdownSource, handleMarkdownChange]);
const markdownPreviewHtml = useMemo(
() => renderPostContentHtml(sanitizeHtml(markdownToHtml(markdownSource)), true),
() => sanitizeHtml(markdownToHtml(markdownSource)),
[markdownSource],
);
@@ -384,7 +384,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{
icon: <LockKeyhole size={15} />,
title: '登录可见',
hint: '独立输入区;Ctrl+Enter 退出',
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
active: editor.isActive('membersOnly'),
className: 'article-tool-btn--members',
action: wrapMembersOnly,
@@ -451,9 +451,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
</div>
<div className="article-editor-markdown-preview">
<div className="article-editor-markdown-preview-label"></div>
<div
<PostContent
html={markdownPreviewHtml}
isLoggedIn
className="article-editor-markdown-preview-body post-detail-content"
dangerouslySetInnerHTML={{ __html: markdownPreviewHtml }}
/>
</div>
</div>

View File

@@ -0,0 +1,149 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ListTree } from 'lucide-react';
import type { PostHeading } from '../utils/postHeadings';
import { cn } from '@/lib/utils';
interface Props {
headings: PostHeading[];
/** 滚动容器;不传则用 viewport */
scrollRoot?: HTMLElement | null;
title?: string;
className?: string;
}
/** 根据滚动位置取当前应高亮的标题 id */
function resolveActiveHeadingId(
headings: PostHeading[],
root: HTMLElement | null,
offsetPx = 28,
): string {
if (headings.length === 0) return '';
const rootTop = root ? root.getBoundingClientRect().top : 0;
const marker = rootTop + offsetPx;
let current = headings[0].id;
for (const h of headings) {
const el = document.getElementById(h.id);
if (!el) continue;
if (el.getBoundingClientRect().top <= marker) {
current = h.id;
} else {
break;
}
}
return current;
}
/** 文章目录树:点击跳转,滚动时高亮当前标题 */
export default function ArticleOutline({
headings,
scrollRoot,
title = '文章目录',
className,
}: Props) {
const [activeId, setActiveId] = useState(headings[0]?.id ?? '');
/** 点击跳转期间锁定高亮,避免 Intersection/滚动回调来回抢 */
const lockUntilRef = useRef(0);
const lockIdRef = useRef('');
const rafRef = useRef(0);
const minLevel = useMemo(
() => (headings.length ? Math.min(...headings.map(h => h.level)) : 2),
[headings],
);
useEffect(() => {
setActiveId(headings[0]?.id ?? '');
lockUntilRef.current = 0;
lockIdRef.current = '';
}, [headings]);
useEffect(() => {
if (headings.length === 0) return undefined;
const root: HTMLElement | Window = scrollRoot ?? window;
const syncActive = () => {
if (Date.now() < lockUntilRef.current) {
if (lockIdRef.current) setActiveId(lockIdRef.current);
return;
}
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
if (next) setActiveId(next);
};
const onScroll = () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(syncActive);
};
syncActive();
root.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
return () => {
root.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [headings, scrollRoot]);
const jumpTo = (id: string) => {
const el = document.getElementById(id);
if (!el) return;
// 立即高亮并锁定一段时间,覆盖 smooth 滚动过程中的中间态
setActiveId(id);
lockIdRef.current = id;
lockUntilRef.current = Date.now() + 900;
const root = scrollRoot;
if (root) {
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// 滚动结束后再按位置校正一次(若用户中途手动滑会自然解锁)
window.setTimeout(() => {
if (lockIdRef.current !== id) return;
lockUntilRef.current = 0;
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
if (next) setActiveId(next);
}, 920);
};
return (
<div className={cn('article-outline', className)}>
<div className="sidebar-section article-outline-head">
<ListTree size={12} aria-hidden />
<span>{title}</span>
</div>
{headings.length === 0 ? (
<p className="article-outline-empty"></p>
) : (
<nav className="article-outline-nav" aria-label="文章目录">
{headings.map(h => (
<button
key={h.id}
type="button"
className={cn(
'article-outline-item',
`article-outline-item--l${Math.min(6, Math.max(1, h.level - minLevel + 1))}`,
activeId === h.id && 'active',
)}
onClick={() => jumpTo(h.id)}
title={h.text}
>
{h.text}
</button>
))}
</nav>
)}
</div>
);
}

View File

@@ -1,7 +1,19 @@
import { Clock, MessageSquare, X } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Clock, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import type { Comment } from '../api/types';
import type { Comment, User } from '../api/types';
import CommentContent from './CommentContent';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
commentNick,
commentInitial,
@@ -10,33 +22,75 @@ import {
buildCommentTree,
type CommentNode,
} from '../utils/comment';
import { isTimeDiffSignificant } from '../utils/content';
import { useForumLimits } from '../hooks/useForumLimits';
function canManageComment(c: Comment, user?: User | null): boolean {
if (!user) return false;
if (user.role === 'admin') return true;
return c.user_id > 0 && c.user_id === user.id;
}
interface ItemProps {
node: CommentNode;
nested?: boolean;
highlightFloor?: number | null;
replyToId?: number | null;
editingId?: number | null;
currentUser?: User | null;
onReply: (comment: Comment) => void;
onCancelReply: () => void;
onStartEdit: (comment: Comment) => void;
onCancelEdit: () => void;
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>;
renderReplyBox?: (comment: Comment) => ReactNode;
}
/** 单条评论(支持嵌套子回复 + 内联回复框) */
/** 单条评论(支持嵌套子回复 + 内联回复框 + 编辑/删除 */
function CommentItem({
node,
nested,
highlightFloor,
replyToId,
editingId,
currentUser,
onReply,
onCancelReply,
onStartEdit,
onCancelEdit,
onSaveEdit,
onDelete,
renderReplyBox,
}: ItemProps) {
const { limits } = useForumLimits();
const c = node.comment;
const nick = commentNick(c);
const guest = isGuestComment(c);
const isHighlighted = highlightFloor === c.floor;
const hidden = !!c.content_hidden;
const isReplying = replyToId === c.id;
const isEditing = editingId === c.id;
const manageable = canManageComment(c, currentUser);
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
const [editText, setEditText] = useState(c.content);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (isEditing) setEditText(c.content);
}, [isEditing, c.content, c.id]);
const handleSave = async () => {
const next = editText.trim();
if (!next) return;
setSaving(true);
try {
await onSaveEdit(c, next);
} finally {
setSaving(false);
}
};
return (
<div
@@ -66,6 +120,29 @@ function CommentItem({
<div className="waline-comment-private-mask">
</div>
) : isEditing ? (
<div className="waline-comment-edit">
<textarea
className="waline-comment-edit-input"
value={editText}
onChange={e => setEditText(e.target.value)}
rows={3}
maxLength={limits.comment_max > 0 ? limits.comment_max : undefined}
/>
<div className="waline-comment-edit-actions">
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelEdit} disabled={saving}>
</button>
<button
type="button"
className="waline-comment-reply-btn"
onClick={handleSave}
disabled={saving || !editText.trim()}
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
) : (
<div className="waline-comment-bubble">
{c.reply_target && (
@@ -79,18 +156,58 @@ function CommentItem({
<span className="waline-comment-date">
<Clock size={14} />
{formatCommentDate(c.created_at)}
{showEdited && <span className="waline-comment-edited"> · </span>}
</span>
{isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
<X size={14} />
</button>
) : (
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
<MessageSquare size={14} />
{!hidden && !isEditing && (
isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
<X size={14} />
</button>
) : (
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
<MessageSquare size={14} />
</button>
)
)}
{!hidden && !isEditing && manageable && (
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
<Pencil size={14} />
</button>
)}
{!hidden && !isEditing && manageable && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button type="button" className="waline-comment-reply-btn cancel" disabled={deleting}>
<Trash2 size={14} />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setDeleting(true);
try {
await onDelete(c);
} finally {
setDeleting(false);
}
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{isReplying && renderReplyBox && (
@@ -108,8 +225,14 @@ function CommentItem({
nested
highlightFloor={highlightFloor}
replyToId={replyToId}
editingId={editingId}
currentUser={currentUser}
onReply={onReply}
onCancelReply={onCancelReply}
onStartEdit={onStartEdit}
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onDelete={onDelete}
renderReplyBox={renderReplyBox}
/>
))}
@@ -124,8 +247,14 @@ interface Props {
comments: Comment[];
highlightFloor?: number | null;
replyToId?: number | null;
editingId?: number | null;
currentUser?: User | null;
onReply: (comment: Comment) => void;
onCancelReply: () => void;
onStartEdit: (comment: Comment) => void;
onCancelEdit: () => void;
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>;
renderReplyBox?: (comment: Comment) => ReactNode;
}
@@ -134,8 +263,14 @@ export default function CommentThreadList({
comments,
highlightFloor,
replyToId,
editingId,
currentUser,
onReply,
onCancelReply,
onStartEdit,
onCancelEdit,
onSaveEdit,
onDelete,
renderReplyBox,
}: Props) {
const tree = buildCommentTree(comments);
@@ -148,8 +283,14 @@ export default function CommentThreadList({
node={node}
highlightFloor={highlightFloor}
replyToId={replyToId}
editingId={editingId}
currentUser={currentUser}
onReply={onReply}
onCancelReply={onCancelReply}
onStartEdit={onStartEdit}
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onDelete={onDelete}
renderReplyBox={renderReplyBox}
/>
))}

View File

@@ -0,0 +1,34 @@
import { Skeleton } from '@/components/ui/skeleton';
import PostListSkeleton from './PostListSkeleton';
/** 首页 Feed 初始骨架(标题区 + 排序栏 + 列表) */
export default function FeedPageSkeleton() {
return (
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
<div className="feed-panel">
<div className="feed-top">
<div className="feed-head">
<div className="feed-head__title">
<Skeleton className="skeleton--feed-title" />
<div className="feed-head__stats">
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
</div>
</div>
</div>
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<span className="feed-toolbar__spacer" />
<Skeleton className="skeleton--count" />
</div>
</div>
<div className="post-list-scroll">
<PostListSkeleton />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
page: number;
totalPages: number;
postTotal: number;
loading?: boolean;
onPageChange: (page: number) => void;
}
/** 生成页码窗口:两端 + 当前邻页,中间用省略号 */
function buildPageItems(current: number, total: number): Array<number | 'gap'> {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const set = new Set<number>();
set.add(1);
set.add(total);
for (let i = current - 1; i <= current + 1; i++) {
if (i >= 1 && i <= total) set.add(i);
}
// 靠近端点时多露出几页,避免 1 … 2 3 这种浪费
if (current <= 3) {
set.add(2);
set.add(3);
set.add(4);
}
if (current >= total - 2) {
set.add(total - 1);
set.add(total - 2);
set.add(total - 3);
}
const sorted = [...set].sort((a, b) => a - b);
const items: Array<number | 'gap'> = [];
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) items.push('gap');
items.push(sorted[i]);
}
return items;
}
export default function FeedPagination({
page,
totalPages,
postTotal,
loading = false,
onPageChange,
}: Props) {
const [jumpInput, setJumpInput] = useState(String(page));
const pageItems = buildPageItems(page, totalPages);
const showJump = totalPages > 5;
useEffect(() => {
setJumpInput(String(page));
}, [page]);
const commitJump = () => {
if (loading) return;
const n = Number.parseInt(jumpInput, 10);
if (!Number.isFinite(n)) {
setJumpInput(String(page));
return;
}
const target = Math.min(totalPages, Math.max(1, n));
setJumpInput(String(target));
if (target !== page) onPageChange(target);
};
return (
<nav className="feed-pagination" aria-label="帖子分页">
<p className="feed-pagination__meta" aria-live="polite">
<strong>{postTotal}</strong>
</p>
<div className="feed-pagination__pages">
<button
type="button"
className="feed-pagination__nav"
disabled={loading || page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
<ChevronLeft aria-hidden size={16} strokeWidth={2} />
</button>
{pageItems.map((item, idx) =>
item === 'gap' ? (
<span key={`gap-${idx}`} className="feed-pagination__gap" aria-hidden>
</span>
) : (
<button
key={item}
type="button"
className={cn('feed-pagination__page', item === page && 'is-active')}
disabled={loading || item === page}
aria-label={`${item}`}
aria-current={item === page ? 'page' : undefined}
onClick={() => onPageChange(item)}
>
{item}
</button>
),
)}
<button
type="button"
className="feed-pagination__nav"
disabled={loading || page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRight aria-hidden size={16} strokeWidth={2} />
</button>
</div>
{showJump && (
<form
className="feed-pagination__jump"
onSubmit={(e) => {
e.preventDefault();
commitJump();
}}
>
<label htmlFor="feed-page-jump" className="feed-pagination__jump-label">
</label>
<input
id="feed-page-jump"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={jumpInput}
disabled={loading}
onChange={(e) => setJumpInput(e.target.value.replace(/\D/g, ''))}
onBlur={commitJump}
className="feed-pagination__jump-input"
aria-label={`跳转到指定页,共 ${totalPages}`}
/>
<span className="feed-pagination__jump-suffix">/ {totalPages}</span>
</form>
)}
</nav>
);
}

View File

@@ -1,9 +1,20 @@
/** 路由懒加载时的轻量占位,避免引入 Arco Spin 增大首屏 */
export default function PageLoader() {
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
type PageLoaderProps = {
/** 独立全屏路由(登录/注册)占满视口居中 */
fullScreen?: boolean;
};
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton避免非 Feed 页闪出鱼骨骨架 */
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
return (
<div className="page-loader" role="status" aria-live="polite">
<span className="page-loader__dot" />
<div
className={cn('page-loader', fullScreen && 'page-loader--viewport')}
aria-busy="true"
aria-label="加载中"
>
<Spinner size="lg" />
</div>
);
}

View File

@@ -1,32 +1,72 @@
import { useMemo, useCallback } from 'react';
import { useMemo, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { renderPostContentHtml } from '../utils/postContent';
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
import { loginPath, registerPath } from '../utils/authRedirect';
import { useForumLimits } from '../hooks/useForumLimits';
import { notify } from '@/lib/notify';
interface Props {
html: string;
isLoggedIn: boolean;
className?: string;
/** 正文标题树变化时回调(用于侧栏目录) */
onHeadingsChange?: (headings: PostHeading[]) => void;
}
/** 帖子正文渲染(含会员专属区块) */
export default function PostContent({ html, isLoggedIn, className = 'post-detail-content' }: Props) {
/** 帖子正文渲染(含会员专属区块、代码块美化 */
export default function PostContent({
html,
isLoggedIn,
className = 'post-detail-content',
onHeadingsChange,
}: Props) {
const nav = useNavigate();
const { limits } = useForumLimits();
const rendered = useMemo(
() => renderPostContentHtml(html, isLoggedIn),
[html, isLoggedIn],
);
const prepared = useMemo(() => {
const rendered = renderPostContentHtml(html, isLoggedIn, {
openLinksInNewTab: limits.open_content_links_in_new_tab,
});
return {
html: rendered,
headings: extractHeadingsFromHtml(rendered),
};
}, [html, isLoggedIn, limits.open_content_links_in_new_tab]);
const handleClick = useCallback((e: React.MouseEvent) => {
useEffect(() => {
onHeadingsChange?.(prepared.headings);
}, [prepared.headings, onHeadingsChange]);
const handleClick = useCallback(async (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-members-login]')) {
e.preventDefault();
nav('/login');
nav(loginPath());
return;
}
if (target.closest('[data-members-register]')) {
e.preventDefault();
nav('/register');
nav(registerPath());
return;
}
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
if (copyBtn) {
e.preventDefault();
const block = copyBtn.closest('.md-codeblock');
const text = block?.querySelector('pre')?.textContent ?? '';
try {
await navigator.clipboard.writeText(text);
const prev = copyBtn.textContent;
copyBtn.textContent = '已复制';
copyBtn.classList.add('is-copied');
window.setTimeout(() => {
copyBtn.textContent = prev || '复制';
copyBtn.classList.remove('is-copied');
}, 1600);
} catch {
notify.error('复制失败');
}
}
}, [nav]);
@@ -34,7 +74,7 @@ export default function PostContent({ html, isLoggedIn, className = 'post-detail
<div
className={className}
onClick={handleClick}
dangerouslySetInnerHTML={{ __html: rendered }}
dangerouslySetInnerHTML={{ __html: prepared.html }}
/>
);
}

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import { MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import PinnedIcon from '@/components/PinnedIcon';
@@ -8,10 +9,10 @@ import { formatTime } from '../utils/content';
interface Props {
post: PostItem;
sort?: FeedSort;
onClick: () => void;
onSelect: (id: number) => void;
}
export default function PostListItem({ post, sort = 'latest', onClick }: Props) {
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
const initial = post.user?.nickname?.[0] || '?';
const timeLabel = sort === 'reply'
? (post.last_reply_at
@@ -22,7 +23,7 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
const likeCount = post.like_count ?? 0;
return (
<button type="button" className="post-row" onClick={onClick}>
<button type="button" className="post-row" onClick={() => onSelect(post.id)}>
<div className="post-avatar">
{post.user?.avatar
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
@@ -52,3 +53,5 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
</button>
);
}
export default memo(PostListItem);

View File

@@ -1,12 +1,17 @@
import { Flame, Megaphone, Users } from 'lucide-react';
import type { PostItem, Notification, OnlineStats } from '../api/types';
import { Flame, MessageCircle, Tags } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import type { PostItem, RecentComment, TagCount } from '../api/types';
import { useSiteBranding } from '../hooks/useSiteBranding';
import TagCloud from './TagCloud';
interface Props {
hot: PostItem[];
notifications: Notification[];
online: OnlineStats | null;
recentComments: RecentComment[];
tags?: TagCount[];
tagsLoading?: boolean;
onPostClick: (id: number) => void;
/** 首次拉取中,避免空态闪烁 */
/** 首次拉取中,显示骨架避免空态闪烁 */
loading?: boolean;
}
@@ -17,16 +22,46 @@ function hotRankClass(index: number): string {
return 'widget-rank';
}
function HotSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="热门加载中">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="widget-item widget-item--skeleton">
<Skeleton className="skeleton--widget-rank" />
<Skeleton className="skeleton--widget-title" style={{ width: `${62 + (i % 4) * 8}%` }} />
</div>
))}
</div>
);
}
function CommentSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="评论加载中">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="widget-item widget-item--comment widget-item--skeleton">
<Skeleton className="skeleton--widget-avatar" />
<Skeleton className="skeleton--widget-title" style={{ width: `${55 + (i % 3) * 12}%` }} />
<Skeleton className="skeleton--widget-time" />
</div>
))}
</div>
);
}
export default function RightPanel({
hot,
notifications,
online,
recentComments,
tags = [],
tagsLoading = false,
onPostClick,
loading = false,
}: Props) {
const { branding } = useSiteBranding();
const [params] = useSearchParams();
const activeTag = params.get('keyword') || '';
const hotList = hot?.slice(0, 8) ?? [];
const noticeList = notifications?.slice(0, 6) ?? [];
const members = online?.users ?? [];
const commentList = recentComments?.slice(0, 6) ?? [];
return (
<div className="aside-panel-inner">
@@ -37,7 +72,7 @@ export default function RightPanel({
</div>
<div className="widget-card-body">
{loading && hotList.length === 0 ? (
<div className="widget-empty"></div>
<HotSkeleton />
) : hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
@@ -54,65 +89,53 @@ export default function RightPanel({
</div>
</div>
<div className="widget-card widget-card--tags">
<div className="widget-card-head">
<Tags className="widget-card-icon widget-card-icon--tags" aria-hidden />
</div>
<div className="widget-card-body widget-card-body--tags">
<TagCloud tags={tags} loading={tagsLoading} activeTag={activeTag} />
</div>
</div>
<div className="widget-card">
<div className="widget-card-head">
<Megaphone className="widget-card-icon widget-card-icon--notice" aria-hidden />
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
</div>
<div className="widget-card-body">
{loading && noticeList.length === 0 ? (
<div className="widget-empty"></div>
) : noticeList.length === 0 ? (
<div className="widget-empty"></div>
) : noticeList.map(item => (
{loading && commentList.length === 0 ? (
<CommentSkeleton />
) : commentList.length === 0 ? (
<div className="widget-empty"></div>
) : commentList.map(item => (
<button
key={item.id}
type="button"
className="widget-item widget-item--notice"
onClick={() => onPostClick(item.id)}
className="widget-item widget-item--comment"
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
onClick={() => onPostClick(item.post_id)}
>
<span className="widget-item-title">{item.title}</span>
<span className="widget-item-avatar" aria-hidden>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</span>
<span className="widget-item-title">{item.excerpt}</span>
<span className="widget-item-time">{item.created_at}</span>
</button>
))}
</div>
</div>
<div className="widget-card">
<div className="widget-card-head">
<Users className="widget-card-icon widget-card-icon--online" aria-hidden />
<span className="widget-head-count">{online?.count ?? '—'}</span>
</div>
<div className="widget-card-body">
<div className="widget-online-meta">
{online?.members ?? 0} · {online?.guests ?? 0}
</div>
<div className="widget-online-list">
{loading && online == null ? (
<span className="widget-empty widget-empty--inline"></span>
) : (
<>
{members.map(u => (
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
{u.avatar
? <img src={u.avatar} alt="" loading="lazy" decoding="async" />
: (u.nickname?.[0] || '?')}
</span>
))}
{members.length === 0 && (
<span className="widget-empty widget-empty--inline">线</span>
)}
</>
)}
</div>
</div>
</div>
<div className="widget-card widget-card--about">
<div className="widget-card-body">
<p className="widget-about-text">
<strong></strong>
<strong>{branding.name}</strong>
{branding.slogan
? `${branding.slogan}${branding.name_en ? ` · ${branding.name_en}` : ''}`
: (branding.name_en || '轻量社区')}
</p>
</div>
</div>

View File

@@ -1,14 +1,17 @@
import {
Home, Star, LayoutDashboard,
Home, Star, LayoutDashboard, FolderGit2, ArrowLeft,
} from 'lucide-react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import type { Board } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import BoardIconDisplay from './BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
import ArticleOutline from './ArticleOutline';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
@@ -20,6 +23,7 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
if (isNeutralSidebarRoute(pathname)) return null;
if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/projects')) return 'projects';
if (pathname.startsWith('/admin')) return 'admin';
return activeBoard === 0 ? 'all' : String(activeBoard);
}
@@ -28,9 +32,25 @@ interface Props {
boards: Board[];
activeBoard: number;
onSelectBoard: (id: number) => void;
/** 板块列表首次拉取中 */
boardsLoading?: boolean;
/** 帖子详情:左侧切换为文章目录 */
outlineMode?: boolean;
outlineHeadings?: PostHeading[];
outlineScrollRoot?: HTMLElement | null;
outlineTitle?: string;
}
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
export default function Sidebar({
boards,
activeBoard,
onSelectBoard,
boardsLoading = false,
outlineMode = false,
outlineHeadings = [],
outlineScrollRoot = null,
outlineTitle,
}: Props) {
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
@@ -52,15 +72,48 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
</button>
);
if (outlineMode) {
return (
<aside className="sidebar sidebar--outline">
<button
type="button"
className="sidebar-nav-item sidebar-outline-back"
onClick={() => navigateFeed(nav, '/')}
>
<ArrowLeft aria-hidden />
<span className="flex-1 truncate"></span>
</button>
<ArticleOutline
headings={outlineHeadings}
scrollRoot={outlineScrollRoot}
title={outlineTitle || '文章目录'}
/>
</aside>
);
}
return (
<aside className="sidebar">
<div className="sidebar-section"></div>
<nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
</nav>
{boards.length > 0 && (
{(boardsLoading && boards.length === 0) ? (
<>
<div className="sidebar-section sidebar-section--boards"></div>
<nav className="sidebar-nav sidebar-nav--skeleton" aria-busy="true" aria-label="板块加载中">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="sidebar-nav-item sidebar-nav-item--skeleton">
<Skeleton className="skeleton--sidebar-icon" />
<Skeleton className="skeleton--sidebar-label" style={{ width: `${58 + (i % 3) * 12}%` }} />
</div>
))}
</nav>
</>
) : boards.length > 0 ? (
<>
<div className="sidebar-section sidebar-section--boards"></div>
<nav className="sidebar-nav">
@@ -92,7 +145,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
})}
</nav>
</>
)}
) : null}
{isAdmin && (
<>

View File

@@ -0,0 +1,26 @@
import type { SiteBranding } from '../api/types';
import { cn } from '@/lib/utils';
interface Props {
branding: SiteBranding;
/** CSS 类header-logo-mark / logo-mark / admin-topbar-mark */
className?: string;
/** 有 Logo 图时用的额外类名 */
imgClassName?: string;
}
/** 站点字标或 Logo 图 */
export default function SiteBrandMark({ branding, className, imgClassName }: Props) {
if (branding.logo) {
return (
<img
src={branding.logo}
alt={branding.name}
className={cn(className, 'site-brand-logo-img', imgClassName)}
loading="lazy"
decoding="async"
/>
);
}
return <span className={className}>{branding.logo_mark || branding.name.charAt(0) || '?'}</span>;
}

View File

@@ -0,0 +1,114 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { TagCount } from '../api/types';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface Props {
tags: TagCount[];
loading?: boolean;
activeTag?: string;
}
type TagTone = 0 | 1 | 2 | 3 | 4 | 5;
/** 稳定哈希,让同一标签颜色固定 */
function hashTone(name: string): TagTone {
let h = 0;
for (let i = 0; i < name.length; i++) {
h = (h * 31 + name.charCodeAt(i)) >>> 0;
}
return (h % 6) as TagTone;
}
/** 权重档位 04驱动字号与透明度 */
function weightTier(count: number, min: number, max: number): number {
if (max <= min) return 2;
const t = (count - min) / (max - min);
return Math.min(4, Math.max(0, Math.round(t * 4)));
}
/**
* 打散排序:热门标签穿插分布,避免「大标签全挤在顶上」。
* 用名称哈希做次级键,视觉更像云而非排行榜。
*/
function layoutTags(tags: TagCount[]): TagCount[] {
const ranked = [...tags].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'zh'));
const top = ranked.slice(0, Math.min(6, ranked.length));
const rest = ranked.slice(top.length);
const out: TagCount[] = [];
let i = 0;
let j = 0;
while (i < top.length || j < rest.length) {
if (j < rest.length) out.push(rest[j++]);
if (i < top.length) out.push(top[i++]);
if (j < rest.length) out.push(rest[j++]);
}
return out;
}
/** 右侧栏标签云:按热度缩放,色调错落 */
export default function TagCloud({ tags, loading = false, activeTag = '' }: Props) {
const nav = useNavigate();
const { items, min, max } = useMemo(() => {
if (tags.length === 0) return { items: [] as TagCount[], min: 1, max: 1 };
let lo = tags[0].count;
let hi = tags[0].count;
for (const t of tags) {
if (t.count < lo) lo = t.count;
if (t.count > hi) hi = t.count;
}
return { items: layoutTags(tags), min: lo, max: hi };
}, [tags]);
if (loading && tags.length === 0) {
return (
<div className="tag-cloud tag-cloud--skeleton" aria-busy="true" aria-label="标签加载中">
{Array.from({ length: 10 }, (_, i) => (
<Skeleton
key={i}
className="skeleton--tag-cloud"
style={{
width: `${42 + (i % 5) * 16}px`,
height: `${20 + (i % 3) * 4}px`,
}}
/>
))}
</div>
);
}
if (items.length === 0) {
return <div className="tag-cloud-empty"></div>;
}
return (
<div className="tag-cloud" role="list" aria-label="标签云">
{items.map((tag, index) => {
const active = activeTag.trim().toLowerCase() === tag.name.toLowerCase();
const tier = weightTier(tag.count, min, max);
const tone = hashTone(tag.name);
return (
<button
key={tag.name}
type="button"
role="listitem"
className={cn(
'tag-cloud-item',
`tag-cloud-item--w${tier}`,
`tag-cloud-item--t${tone}`,
`tag-cloud-item--r${index % 5}`,
active && 'active',
)}
title={`${tag.name} · ${tag.count}`}
onClick={() => nav(`/?keyword=${encodeURIComponent(tag.name)}`)}
>
<span className="tag-cloud-item__name">{tag.name}</span>
{tier >= 3 && <span className="tag-cloud-item__count">{tag.count}</span>}
</button>
);
})}
</div>
);
}

View File

@@ -1,9 +1,13 @@
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
@@ -11,15 +15,16 @@ interface Props {
posts: PostItem[];
sort?: FeedSort;
loading: boolean;
/** 当前页之后是否还有更多 */
hasMore: boolean;
/** 是否允许滚动触底自动加载(达到上限后为 false */
canAutoLoad: boolean;
/** 是否显示底部分页控件 */
showPagination: boolean;
page: number;
totalPages: number;
postTotal: number;
onLoadMore: () => void;
onPageChange: (page: number) => void;
onSelect: (id: number) => void;
/** 返回列表时恢复的滚动位置 */
restoreScrollTop?: number | null;
/** 递增时强制回到列表顶部(主动刷新导航) */
resetScrollKey?: number;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
@@ -30,17 +35,25 @@ export default function VirtualPostList({
sort = 'latest',
loading,
hasMore,
canAutoLoad,
showPagination,
page,
totalPages,
postTotal,
onLoadMore,
onPageChange,
onSelect,
restoreScrollTop,
resetScrollKey = 0,
onScrollTopChange,
onScrollRestored,
}: Props) {
const nav = useNavigate();
const { user } = useAuth();
const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const onScrollTopChangeRef = useRef(onScrollTopChange);
const onScrollRestoredRef = useRef(onScrollRestored);
onScrollTopChangeRef.current = onScrollTopChange;
onScrollRestoredRef.current = onScrollRestored;
const virtualizer = useVirtualizer({
count: posts.length,
@@ -53,10 +66,8 @@ export default function VirtualPostList({
: undefined,
});
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
const showEnd = !hasMore && posts.length > 0 && !loading;
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isLoadingMore = loading && posts.length > 0;
const isEmpty = !loading && posts.length === 0;
useLayoutEffect(() => {
@@ -67,15 +78,15 @@ export default function VirtualPostList({
virtualizer.scrollToOffset(0);
}
restoredRef.current = true;
onScrollTopChange?.(0);
}, [resetScrollKey, virtualizer, onScrollTopChange]);
onScrollTopChangeRef.current?.(0);
}, [resetScrollKey, virtualizer]);
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
virtualizer.scrollToOffset(restoreScrollTop);
restoredRef.current = true;
onScrollRestored?.();
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
onScrollRestoredRef.current?.();
}, [restoreScrollTop, posts.length, virtualizer]);
useEffect(() => {
restoredRef.current = false;
@@ -85,33 +96,42 @@ export default function VirtualPostList({
const el = parentRef.current;
if (!el) return;
const onScroll = () => {
onScrollTopChange?.(el.scrollTop);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && canAutoLoad && hasMore && !loading) {
onLoadMore();
}
onScrollTopChangeRef.current?.(el.scrollTop);
};
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, [canAutoLoad, hasMore, loading, onLoadMore, onScrollTopChange]);
}, []);
return (
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton />
) : isEmpty ? (
<div className="empty-feed">
<div className="empty-feed" role="status">
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<p className="empty-feed-hint"></p>
<div className="empty-feed-actions">
{user ? (
<Button type="button" size="sm" onClick={() => nav('/compose')}>
</Button>
) : (
<Button type="button" size="sm" onClick={() => nav(loginPath('/compose'))}>
</Button>
)}
</div>
</div>
) : (
<>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const post = posts[vi.index];
if (!post) return null;
return (
<div
key={post.id}
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
@@ -122,20 +142,20 @@ export default function VirtualPostList({
transform: `translateY(${vi.start}px)`,
}}
>
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
<PostListItem post={post} sort={sort} onSelect={onSelect} />
</div>
);
})}
</div>
{isLoadingMore && <PostListSkeleton count={2} />}
{showHistoryPrompt && (
<div className="feed-list-footer feed-list-footer--history">
<p className="feed-list-footer__hint">
{posts.length} / {postTotal}
</p>
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
</Button>
{showPagination && (
<div className="feed-list-footer feed-list-footer--pagination">
<FeedPagination
page={page}
totalPages={totalPages}
postTotal={postTotal}
loading={loading}
onPageChange={onPageChange}
/>
</div>
)}
{showEnd && (

View File

@@ -1,62 +1,106 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import {
ReactNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
type NodeViewProps,
} from '@tiptap/react';
import { LockKeyhole, LogOut } from 'lucide-react';
import { LockKeyhole, Trash2 } from 'lucide-react';
/** 查找光标所在的登录可见节点深度 */
function findMembersOnlyDepth($pos: { depth: number; node: (d: number) => { type: { name: string } } }): number {
function findMembersOnlyDepth($pos: {
depth: number;
node: (d: number) => { type: { name: string }; nodeSize: number };
before: (d: number) => number;
start: (d: number) => number;
}): number {
for (let d = $pos.depth; d > 0; d -= 1) {
if ($pos.node(d).type.name === 'membersOnly') return d;
}
return -1;
}
/** 登录可见区块是否无实质文字 */
function isMembersOnlyEmpty(node: ProseMirrorNode): boolean {
return node.textContent.trim().length === 0;
}
/** 编辑态「登录可见」区块视图 */
function MembersOnlyView({ selected, editor }: NodeViewProps) {
const handleExit = () => {
editor.chain().focus().exitMembersOnly().run();
function MembersOnlyView({ selected, editor, node, getPos }: NodeViewProps) {
const empty = isMembersOnlyEmpty(node);
/** 按 NodeView 自身位置删除,不依赖光标是否仍在块内 */
const deleteThisBlock = () => {
const pos = getPos();
if (typeof pos !== 'number') {
editor.chain().focus().removeMembersOnly().run();
return;
}
editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
if (dispatch) tr.delete(pos, pos + node.nodeSize);
return true;
})
.run();
};
const handleUnwrap = () => {
editor.chain().focus().unwrapMembersOnly().run();
const pos = getPos();
if (typeof pos !== 'number') {
editor.chain().focus().unwrapMembersOnly().run();
return;
}
editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
if (isMembersOnlyEmpty(node)) {
if (dispatch) tr.delete(pos, pos + node.nodeSize);
} else if (dispatch) {
tr.replaceWith(pos, pos + node.nodeSize, node.content);
}
return true;
})
.run();
};
return (
<NodeViewWrapper
as="members-only"
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}${empty ? ' editor-members-only--empty' : ''}`}
>
<div className="post-members-only__badge" contentEditable={false}>
<span className="post-members-only__badge-icon" aria-hidden="true">
<LockKeyhole size={12} />
</span>
<span></span>
<button
type="button"
className="post-members-only__exit-btn"
title="Ctrl+Enter 退出到公开区域"
onMouseDown={e => e.preventDefault()}
onClick={handleExit}
>
<LogOut size={11} />
退
</button>
<span className="post-members-only__shortcut-hint">Ctrl+Enter 退</span>
<button
type="button"
className="post-members-only__unwrap-btn"
title="取消登录可见包裹,保留正文"
onMouseDown={e => e.preventDefault()}
onClick={handleUnwrap}
>
</button>
<div className="post-members-only__badge-actions">
{!empty && (
<button
type="button"
className="post-members-only__unwrap-btn"
title="取消登录可见包裹,保留正文"
onMouseDown={e => e.preventDefault()}
onClick={handleUnwrap}
>
</button>
)}
<button
type="button"
className="post-members-only__remove-btn"
title={empty ? '删除空的登录可见区块' : '删除整个登录可见区块'}
onMouseDown={e => e.preventDefault()}
onClick={deleteThisBlock}
>
<Trash2 size={11} />
</button>
</div>
</div>
<NodeViewContent className="post-members-only__body" />
<NodeViewContent className="post-members-only__body" data-placeholder="此处内容游客不可见…" />
</NodeViewWrapper>
);
}
@@ -68,6 +112,7 @@ declare module '@tiptap/core' {
wrapMembersOnly: () => ReturnType;
exitMembersOnly: () => ReturnType;
unwrapMembersOnly: () => ReturnType;
removeMembersOnly: () => ReturnType;
};
}
}
@@ -94,6 +139,37 @@ export const MembersOnly = Node.create({
addKeyboardShortcuts() {
return {
// 空区块内 Backspace / Delete整块删除
Backspace: ({ editor }) => {
const { $from, empty } = editor.state.selection;
if (!empty) return false;
const depth = findMembersOnlyDepth($from);
if (depth < 0) return false;
const node = $from.node(depth);
if (!isMembersOnlyEmpty(node)) {
// 有内容时:在区块首字位置再按 Backspace 则解除包裹(与常见编辑器一致)
if ($from.parentOffset !== 0) return false;
const start = $from.start(depth);
if ($from.pos !== start) return false;
return editor.commands.unwrapMembersOnly();
}
return editor.commands.removeMembersOnly();
},
Delete: ({ editor }) => {
const { $from, empty } = editor.state.selection;
if (!empty) return false;
const depth = findMembersOnlyDepth($from);
if (depth < 0) return false;
const node = $from.node(depth);
if (!isMembersOnlyEmpty(node)) return false;
return editor.commands.removeMembersOnly();
},
// 在区块末尾空行按 Enter 时退出到公开区域
Enter: ({ editor }) => {
const { $from, empty } = editor.state.selection;
@@ -107,6 +183,12 @@ export const MembersOnly = Node.create({
const isEmptyBlock = parent.textContent.trim().length === 0;
if (!atBlockEnd || !isEmptyBlock) return false;
// 整块为空时直接删除,避免退出后仍残留空登录可见壳
const membersNode = $from.node(depth);
if (isMembersOnlyEmpty(membersNode) && membersNode.childCount <= 1) {
return editor.commands.removeMembersOnly();
}
return editor.commands.exitMembersOnly();
},
// Ctrl+Enter / Cmd+Enter 退出到公开区域
@@ -162,7 +244,25 @@ export const MembersOnly = Node.create({
const pos = $from.before(depth);
const node = $from.node(depth);
tr.replaceWith(pos, pos + node.nodeSize, node.content);
// 空区块:直接删除,避免留下空段落套壳
if (isMembersOnlyEmpty(node)) {
tr.delete(pos, pos + node.nodeSize);
} else {
tr.replaceWith(pos, pos + node.nodeSize, node.content);
}
if (dispatch) dispatch(tr);
return true;
},
removeMembersOnly: () => ({ tr, state, dispatch }) => {
const { $from } = state.selection;
const depth = findMembersOnlyDepth($from);
if (depth < 0) return false;
const pos = $from.before(depth);
const node = $from.node(depth);
tr.delete(pos, pos + node.nodeSize);
if (dispatch) dispatch(tr);
return true;
},

View File

@@ -13,7 +13,8 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
/* 需高于全屏编辑器 (z-120),否则未保存提示会被挡住 */
'fixed inset-0 z-[200] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
@@ -31,7 +32,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-[50%] top-[50%] z-[200] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}

View File

@@ -15,7 +15,8 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
/* 需高于全屏编辑器 (z-120) */
'fixed inset-0 z-[200] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
@@ -32,7 +33,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-[50%] top-[50%] z-[200] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}