新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
149
frontend/src/components/ArticleOutline.tsx
Normal file
149
frontend/src/components/ArticleOutline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
34
frontend/src/components/FeedPageSkeleton.tsx
Normal file
34
frontend/src/components/FeedPageSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/FeedPagination.tsx
Normal file
149
frontend/src/components/FeedPagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
26
frontend/src/components/SiteBrandMark.tsx
Normal file
26
frontend/src/components/SiteBrandMark.tsx
Normal 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>;
|
||||
}
|
||||
114
frontend/src/components/TagCloud.tsx
Normal file
114
frontend/src/components/TagCloud.tsx
Normal 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;
|
||||
}
|
||||
|
||||
/** 权重档位 0–4,驱动字号与透明度 */
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user