支持板块图标与主题色自定义,并优化列表加载与未保存离开提示。
新增后端图标校验与色槽规范化,前端展示 BoardBadge/骨架屏,发帖与板块管理页增加未保存确认。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
93
frontend/src/components/BoardAppearancePicker.tsx
Normal file
93
frontend/src/components/BoardAppearancePicker.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
BOARD_ICON_OPTIONS,
|
||||
BOARD_PALETTE_SIZE,
|
||||
getBoardIcon,
|
||||
getBoardThemeIndex,
|
||||
} from '../utils/boardTheme';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
interface IconPickerProps {
|
||||
value: string;
|
||||
onChange: (key: string) => void;
|
||||
board: Pick<Board, 'id' | 'color_index'>;
|
||||
}
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: number;
|
||||
onChange: (index: number) => void;
|
||||
boardId: number;
|
||||
}
|
||||
|
||||
/** 板块图标选择器 */
|
||||
export function BoardIconPicker({ value, onChange, board }: IconPickerProps) {
|
||||
const previewBoard = { id: board.id, icon: value || undefined, color_index: board.color_index };
|
||||
const PreviewIcon = getBoardIcon(previewBoard);
|
||||
const themeIdx = getBoardThemeIndex(previewBoard);
|
||||
|
||||
return (
|
||||
<div className="board-appearance-picker">
|
||||
<div className="board-appearance-picker__preview">
|
||||
<span className={cn('board-appearance-picker__preview-icon', `sidebar-board-icon--${themeIdx}`)}>
|
||||
<PreviewIcon aria-hidden />
|
||||
</span>
|
||||
<span className="board-appearance-picker__preview-hint">预览</span>
|
||||
</div>
|
||||
<div className="board-icon-grid" role="listbox" aria-label="选择板块图标">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!value}
|
||||
className={cn('board-icon-option', !value && 'board-icon-option--active')}
|
||||
title="自动(按板块 ID)"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<span className="board-icon-option__auto">A</span>
|
||||
</button>
|
||||
{BOARD_ICON_OPTIONS.map(({ key, label, Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === key}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn('board-icon-option', value === key && 'board-icon-option--active')}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 板块色标选择器(-1 为自动) */
|
||||
export function BoardColorPicker({ value, onChange, boardId }: ColorPickerProps) {
|
||||
return (
|
||||
<div className="board-color-grid" role="listbox" aria-label="选择板块色标">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value < 0}
|
||||
className={cn('board-color-option board-color-option--auto', value < 0 && 'board-color-option--active')}
|
||||
title="自动(按板块 ID)"
|
||||
onClick={() => onChange(-1)}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
{Array.from({ length: BOARD_PALETTE_SIZE }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === i}
|
||||
className={cn('board-color-option', `board-color-option--${i}`, value === i && 'board-color-option--active')}
|
||||
title={`色标 ${i + 1}`}
|
||||
onClick={() => onChange(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/BoardBadge.tsx
Normal file
18
frontend/src/components/BoardBadge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
board: Pick<Board, 'id' | 'name' | 'color_index'>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 按板块配置或 id 映射不同色标的 Badge */
|
||||
export default function BoardBadge({ board, className }: Props) {
|
||||
const idx = getBoardThemeIndex(board);
|
||||
return (
|
||||
<span className={cn('board-badge', `board-badge--${idx}`, className)}>
|
||||
{board.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/BoardIconDisplay.tsx
Normal file
14
frontend/src/components/BoardIconDisplay.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getBoardIcon } from '../utils/boardTheme';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
/** 渲染板块 Lucide 图标 */
|
||||
export default function BoardIconDisplay({
|
||||
board,
|
||||
className,
|
||||
}: {
|
||||
board: Pick<Board, 'id' | 'icon' | 'color_index'>;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = getBoardIcon(board);
|
||||
return <Icon className={className} aria-hidden />;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Users, FileText, LayoutGrid } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Board, ForumStats } from '../api/types';
|
||||
|
||||
@@ -24,13 +25,20 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal
|
||||
<div className="feed-head__title">
|
||||
<h2 title={boardHint || undefined}>{title}</h2>
|
||||
{!keyword && stats && (
|
||||
<span className="feed-head__meta">
|
||||
会员 <strong>{stats.users}</strong>
|
||||
<span className="feed-head__dot" aria-hidden>·</span>
|
||||
帖子 <strong>{stats.posts}</strong>
|
||||
<span className="feed-head__dot" aria-hidden>·</span>
|
||||
板块 <strong>{stats.boards}</strong>
|
||||
</span>
|
||||
<div className="feed-head__stats">
|
||||
<span className="feed-stat-chip">
|
||||
<Users aria-hidden />
|
||||
会员 <strong>{stats.users}</strong>
|
||||
</span>
|
||||
<span className="feed-stat-chip">
|
||||
<FileText aria-hidden />
|
||||
帖子 <strong>{stats.posts}</strong>
|
||||
</span>
|
||||
<span className="feed-stat-chip">
|
||||
<LayoutGrid aria-hidden />
|
||||
板块 <strong>{stats.boards}</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{keyword && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { MessageCircle, ThumbsUp } from 'lucide-react';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
@@ -17,6 +18,8 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
||||
? `${formatTime(post.last_reply_at)} 回复`
|
||||
: '暂无回复')
|
||||
: formatTime(post.created_at);
|
||||
const commentCount = post.comment_count ?? 0;
|
||||
const likeCount = post.like_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="post-row" onClick={onClick}>
|
||||
@@ -29,14 +32,20 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
||||
{post.title}
|
||||
</div>
|
||||
<div className="post-meta">
|
||||
{post.board && <Badge variant="green">{post.board.name}</Badge>}
|
||||
{post.board && <BoardBadge board={post.board} />}
|
||||
<span>{post.user?.nickname || '匿名'}</span>
|
||||
<span>{timeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
<span>💬 {post.comment_count ?? 0}</span>
|
||||
<span>👍 {post.like_count ?? 0}</span>
|
||||
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}>
|
||||
<MessageCircle aria-hidden />
|
||||
{commentCount}
|
||||
</span>
|
||||
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`}>
|
||||
<ThumbsUp aria-hidden />
|
||||
{likeCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
30
frontend/src/components/PostListSkeleton.tsx
Normal file
30
frontend/src/components/PostListSkeleton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** 帖子列表加载骨架屏 */
|
||||
export default function PostListSkeleton({ count = 8 }: Props) {
|
||||
return (
|
||||
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className="post-row post-row--skeleton">
|
||||
<Skeleton className="skeleton--avatar" />
|
||||
<div className="post-body">
|
||||
<Skeleton className="skeleton--title" style={{ width: `${55 + (i % 4) * 10}%` }} />
|
||||
<div className="skeleton-meta-row">
|
||||
<Skeleton className="skeleton--badge" />
|
||||
<Skeleton className="skeleton--meta" />
|
||||
<Skeleton className="skeleton--meta skeleton--meta-short" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
<Skeleton className="skeleton--stat" />
|
||||
<Skeleton className="skeleton--stat" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Flame, Megaphone, Users } from 'lucide-react';
|
||||
import type { PostItem, Notification, OnlineStats } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
@@ -7,45 +8,61 @@ interface Props {
|
||||
onPostClick: (id: number) => void;
|
||||
}
|
||||
|
||||
function hotRankClass(index: number): string {
|
||||
if (index === 0) return 'widget-rank widget-rank--1';
|
||||
if (index === 1) return 'widget-rank widget-rank--2';
|
||||
if (index === 2) return 'widget-rank widget-rank--3';
|
||||
return 'widget-rank';
|
||||
}
|
||||
|
||||
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) {
|
||||
const hotList = hot?.slice(0, 8) ?? [];
|
||||
const noticeList = notifications?.slice(0, 6) ?? [];
|
||||
const members = online?.users ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="aside-panel-inner">
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">🔥 热门帖子</div>
|
||||
<div className="widget-card-head">
|
||||
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
|
||||
热门帖子
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{hotList.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}>暂无数据</div>
|
||||
<div className="widget-empty">暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
|
||||
<span style={{ color: i < 3 ? '#e74c3c' : 'var(--color-text-3)', fontWeight: 600, minWidth: 18 }}>{i + 1}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
|
||||
<span className={hotRankClass(i)}>{i + 1}</span>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">📢 最新动态</div>
|
||||
<div className="widget-card-head">
|
||||
<Megaphone className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
||||
最新动态
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{noticeList.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}>暂无动态</div>
|
||||
<div className="widget-empty">暂无动态</div>
|
||||
) : noticeList.map(item => (
|
||||
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-4)', flexShrink: 0 }}>{item.created_at}</span>
|
||||
<div key={item.id} className="widget-item widget-item--notice" onClick={() => onPostClick(item.id)}>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
<span className="widget-item-time">{item.created_at}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">👀 当前浏览 {online?.count ?? '—'} 人</div>
|
||||
<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 style={{ fontSize: 12, color: 'var(--color-text-3)', marginBottom: 8 }}>
|
||||
<div className="widget-online-meta">
|
||||
会员 {online?.members ?? 0} · 游客 {online?.guests ?? 0}
|
||||
</div>
|
||||
<div className="widget-online-list">
|
||||
@@ -57,7 +74,7 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
|
||||
</span>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-3)' }}>暂无会员在线</span>
|
||||
<span className="widget-empty widget-empty--inline">暂无会员在线</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,6 +88,6 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useAuth } from '../hooks/useAuth';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
import BoardIconDisplay from './BoardIconDisplay';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
||||
@@ -60,18 +62,34 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
|
||||
{boards.length > 0 && (
|
||||
<>
|
||||
<div className="sidebar-section" style={{ marginTop: 8 }}>板块</div>
|
||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||
<nav className="sidebar-nav">
|
||||
{boards.map(b => (
|
||||
<button
|
||||
type="button"
|
||||
key={b.id}
|
||||
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
|
||||
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
|
||||
>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{boards.map(b => {
|
||||
const isActive = menuKey != null && menuKey === String(b.id);
|
||||
const themeIdx = getBoardThemeIndex(b);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={b.id}
|
||||
className={cn(
|
||||
'sidebar-nav-item',
|
||||
'sidebar-nav-item--board',
|
||||
isActive && 'active',
|
||||
isActive && `sidebar-nav-item--board-${themeIdx}`,
|
||||
)}
|
||||
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
|
||||
>
|
||||
<BoardIconDisplay
|
||||
board={b}
|
||||
className={cn('sidebar-board-icon', `sidebar-board-icon--${themeIdx}`)}
|
||||
/>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
{(b.post_count ?? 0) > 0 && (
|
||||
<span className="sidebar-nav-item__meta">{b.post_count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
45
frontend/src/components/UnsavedChangesDialog.tsx
Normal file
45
frontend/src/components/UnsavedChangesDialog.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onStay: () => void;
|
||||
onLeave: () => void;
|
||||
/** 编辑已有帖子时为 true */
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export default function UnsavedChangesDialog({ open, onStay, onLeave, isEdit = false }: Props) {
|
||||
return (
|
||||
<AlertDialog open={open}>
|
||||
<AlertDialogContent onEscapeKeyDown={onStay}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>放弃未保存的修改?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isEdit
|
||||
? '你对这篇文章的修改尚未保存,离开后将无法恢复。'
|
||||
: '你正在撰写的内容尚未发布,离开后将无法恢复。'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onStay}>继续编辑</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onLeave}
|
||||
>
|
||||
放弃并离开
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PostListItem from './PostListItem';
|
||||
import PostListSkeleton from './PostListSkeleton';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
|
||||
@@ -50,6 +50,8 @@ export default function VirtualPostList({
|
||||
|
||||
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
|
||||
const showEnd = !hasMore && posts.length > 0 && !loading;
|
||||
const isInitialLoad = loading && posts.length === 0;
|
||||
const isLoadingMore = loading && posts.length > 0;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (resetScrollKey <= 0) return;
|
||||
@@ -88,42 +90,44 @@ export default function VirtualPostList({
|
||||
|
||||
return (
|
||||
<div className="post-list-scroll" ref={parentRef}>
|
||||
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const post = posts[vi.index];
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
|
||||
{isInitialLoad ? (
|
||||
<PostListSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const post = posts[vi.index];
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="feed-list-footer">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
{showEnd && (
|
||||
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
|
||||
)}
|
||||
{showEnd && (
|
||||
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
7
frontend/src/components/ui/skeleton.tsx
Normal file
7
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('skeleton', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
Reference in New Issue
Block a user