mosaicmap/app/admin/blogs/blog-table.tsx
2025-08-17 20:28:13 +08:00

1166 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import * as React from "react"
import { useQuery, useMutation, gql } from '@apollo/client';
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconDotsVertical,
IconGripVertical,
IconPlus,
IconTrendingUp,
} from "@tabler/icons-react"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { z } from "zod"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
export const blogSchema = z.object({
id: z.string(),
title: z.string(),
slug: z.string(),
excerpt: z.string().optional(),
content: z.any(), // JSON content
categoryId: z.string().optional(),
status: z.string(),
featuredImage: z.string().optional(),
metaTitle: z.string().optional(),
metaDescription: z.string().optional(),
publishedAt: z.string().optional(),
viewCount: z.number(),
isFeatured: z.boolean(),
isActive: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
createdBy: z.string().optional(),
updatedBy: z.string().optional(),
})
function DragHandle({ id }: { id: string }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof blogSchema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "title",
header: "标题",
cell: ({ row }) => {
return <BlogCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "status",
header: "状态",
cell: ({ row }) => {
const status = row.original.status;
const statusMap: Record<string, { label: string; variant: "secondary" | "default" | "outline" }> = {
draft: { label: "草稿", variant: "secondary" },
published: { label: "已发布", variant: "default" },
archived: { label: "已归档", variant: "outline" },
};
const statusInfo = statusMap[status] || { label: status, variant: "outline" };
return (
<div className="w-20">
<Badge variant={statusInfo.variant} className="text-xs">
{statusInfo.label}
</Badge>
</div>
);
},
},
{
accessorKey: "viewCount",
header: "浏览量",
cell: ({ row }) => (
<div className="w-20">
<span className="text-sm text-muted-foreground">
{row.original.viewCount}
</span>
</div>
),
},
{
accessorKey: "isFeatured",
header: "推荐",
cell: ({ row }) => (
<div className="w-20">
{row.original.isFeatured && (
<Badge variant="default" className="text-xs">
</Badge>
)}
</div>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="px-0 hover:bg-transparent"
>
<IconChevronDown
className={`ml-2 h-4 w-4 transition-transform ${column.getIsSorted() === "asc" ? "rotate-180" : ""
}`}
/>
</Button>
)
},
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
const now = new Date();
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
let timeAgo = '';
if (diffInDays === 0) {
timeAgo = '今天';
} else if (diffInDays === 1) {
timeAgo = '昨天';
} else if (diffInDays < 7) {
timeAgo = `${diffInDays} 天前`;
} else if (diffInDays < 30) {
const weeks = Math.floor(diffInDays / 7);
timeAgo = `${weeks} 周前`;
} else if (diffInDays < 365) {
const months = Math.floor(diffInDays / 30);
timeAgo = `${months} 个月前`;
} else {
const years = Math.floor(diffInDays / 365);
timeAgo = `${years} 年前`;
}
return (
<div className="w-40">
<div className="flex flex-row gap-2">
<span className="text-sm font-medium">
{date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
<Badge variant="secondary" className="text-xs w-fit">
{timeAgo}
</Badge>
</div>
</div>
);
},
enableSorting: true,
sortingFn: (rowA, rowB, columnId) => {
const dateA = new Date(rowA.original.createdAt);
const dateB = new Date(rowB.original.createdAt);
return dateA.getTime() - dateB.getTime();
},
},
{
id: "updatedAt",
accessorFn: (row) => row.updatedAt,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="px-0 hover:bg-transparent"
>
<IconChevronDown
className={`ml-2 h-4 w-4 transition-transform ${column.getIsSorted() === "asc" ? "rotate-180" : ""
}`}
/>
</Button>
)
},
cell: ({ row }) => {
const date = new Date(row.original.updatedAt);
const now = new Date();
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
let timeAgo = '';
if (diffInMinutes < 1) {
timeAgo = '刚刚';
} else if (diffInMinutes < 60) {
timeAgo = `${diffInMinutes} 分钟前`;
} else if (diffInMinutes < 1440) {
const hours = Math.floor(diffInMinutes / 60);
timeAgo = `${hours} 小时前`;
} else {
const days = Math.floor(diffInMinutes / 1440);
timeAgo = `${days} 天前`;
}
return (
<div className="w-40">
<div className="flex flex-row gap-2">
<span className="text-sm font-medium">
{date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
<Badge variant="outline" className="text-xs w-fit">
{timeAgo}
</Badge>
</div>
</div>
);
},
enableSorting: true,
sortingFn: (rowA, rowB, columnId) => {
const dateA = new Date(rowA.original.updatedAt);
const dateB = new Date(rowB.original.updatedAt);
return dateA.getTime() - dateB.getTime();
},
},
{
id: "actions",
cell: ({ row, table }) => {
const updateBlogMutation = (table.options.meta as any)?.updateBlog
const deleteBlogMutation = (table.options.meta as any)?.deleteBlog
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-42">
<DropdownMenuItem
onClick={() => {
window.location.href = `/admin/editor?id=${row.original.id}`
}}
>
</DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
{row.original.status === "draft" && (
<DropdownMenuItem
onClick={() => {
updateBlogMutation({
variables: {
id: row.original.id,
input: { status: "published" }
}
})
}}
>
</DropdownMenuItem>
)}
{row.original.status === "published" && (
<DropdownMenuItem
onClick={() => {
updateBlogMutation({
variables: {
id: row.original.id,
input: { status: "archived" }
}
})
}}
>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
updateBlogMutation({
variables: {
id: row.original.id,
input: { isFeatured: !row.original.isFeatured }
}
})
}}
>
{row.original.isFeatured ? "取消推荐" : "设为推荐"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
updateBlogMutation({
variables: {
id: row.original.id,
input: { isActive: !row.original.isActive }
}
})
}}
>
{row.original.isActive ? "停用" : "启用"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => {
if (confirm('确定要删除这篇博客吗?此操作不可撤销。')) {
deleteBlogMutation({
variables: {
id: row.original.id
}
})
}
}}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof blogSchema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
const GET_BLOGS = gql`
query GetBlogs($filter: BlogFilterInput, $sort: BlogSortInput, $pagination: PaginationInput) {
blogs(filter: $filter, sort: $sort, pagination: $pagination) {
items {
id
title
slug
excerpt
content
categoryId
status
featuredImage
metaTitle
metaDescription
publishedAt
viewCount
isFeatured
isActive
createdAt
updatedAt
createdBy
updatedBy
}
total
page
perPage
totalPages
}
}
`
const GET_BLOG_STATS = gql`
query GetBlogStats {
blogStats {
totalBlogs
publishedBlogs
draftBlogs
archivedBlogs
totalCategories
totalTags
totalViews
}
}
`
const GET_BLOG_TAGS = gql`
query GetBlogTags($filter: BlogTagFilterInput) {
blogTags(filter: $filter) {
id
name
slug
description
color
isActive
createdAt
updatedAt
createdBy
updatedBy
}
}
`
const UPDATE_BLOG = gql`
mutation UpdateBlog($id: UUID!, $input: UpdateBlogInput!) {
updateBlog(id: $id, input: $input) {
id
title
slug
status
isFeatured
isActive
updatedAt
}
}
`
const DELETE_BLOG = gql`
mutation DeleteBlog($id: UUID!) {
deleteBlog(id: $id)
}
`
export function BlogTable() {
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = React.useState<SortingState>([])
const [filter, setFilter] = React.useState<string>()
const { data, loading, error, refetch } = useQuery(GET_BLOGS, {
variables: {
pagination: {
page: pagination.pageIndex + 1,
perPage: pagination.pageSize,
},
sort: sorting.length > 0 ? {
field: sorting[0].id,
direction: sorting[0].desc ? "DESC" : "ASC"
} : {
field: "createdAt",
direction: "DESC"
},
filter: filter ? { status: filter } : undefined
},
fetchPolicy: 'cache-and-network'
})
const { data: statsData, refetch: refetchStats } = useQuery(GET_BLOG_STATS, {
fetchPolicy: 'cache-and-network'
})
const [updateBlog] = useMutation(UPDATE_BLOG, {
onCompleted: () => {
refetch()
refetchStats()
}
})
const [deleteBlog] = useMutation(DELETE_BLOG, {
onCompleted: () => {
refetch()
refetchStats()
}
})
const blogs = data?.blogs?.items || []
const totalCount = data?.blogs?.total || 0
const stats = statsData?.blogStats
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
// Handle drag end for reordering
console.log('Reordering blogs:', active.id, 'to', over.id)
// You can implement actual reordering API call here
}
}
// Use real stats from API or calculate from current data
const blogInfo = React.useMemo(() => {
if (stats) {
return {
totalBlogs: stats.totalBlogs,
totalDrafts: stats.draftBlogs,
totalPublished: stats.publishedBlogs,
totalArchived: stats.archivedBlogs,
totalCategories: stats.totalCategories,
totalTags: stats.totalTags,
totalViews: stats.totalViews,
}
}
// Fallback to calculating from current page data
return {
totalBlogs: totalCount,
totalDrafts: blogs.filter((blog: any) => blog.status === 'draft').length,
totalPublished: blogs.filter((blog: any) => blog.status === 'published').length,
totalArchived: blogs.filter((blog: any) => blog.status === 'archived').length,
totalCategories: 0,
totalTags: 0,
totalViews: blogs.reduce((sum: number, blog: any) => sum + (blog.viewCount || 0), 0),
}
}, [blogs, totalCount, stats])
return (
<>
{error && <div className="text-red-500">{typeof error === 'string' ? error : 'An error occurred'}</div>}
<BlogTabs
blogs={blogs}
loading={loading}
info={blogInfo}
handleDragEnd={handleDragEnd}
refetch={refetch}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
filter={filter}
setFilter={setFilter}
totalCount={totalCount}
updateBlog={updateBlog}
deleteBlog={deleteBlog}
/>
</>
)
}
function BlogTabs({
blogs,
loading,
info,
handleDragEnd,
refetch,
pagination,
setPagination,
sorting,
setSorting,
filter,
setFilter,
totalCount,
updateBlog,
deleteBlog
}: {
blogs: any[]
loading: boolean
info: any
handleDragEnd: (event: DragEndEvent) => void
refetch: any
pagination: { pageIndex: number; pageSize: number }
setPagination: React.Dispatch<React.SetStateAction<{ pageIndex: number; pageSize: number }>>
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
filter?: string
setFilter: React.Dispatch<React.SetStateAction<string | undefined>>
totalCount: number
updateBlog: any
deleteBlog: any
}) {
return (
<div>
<Tabs
defaultValue="all_blogs"
className="w-full flex-col justify-start gap-6"
>
{/* Blog Stats Section */}
{info && (
<div className="px-4 lg:px-6 py-4 bg-muted/50 rounded-lg mx-4 lg:mx-6">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-blue-600">{info.totalBlogs}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">{info.totalPublished}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-yellow-600">{info.totalDrafts}</div>
<div className="text-xs text-muted-foreground">稿</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-600">{info.totalArchived}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
{info.totalCategories !== undefined && (
<div>
<div className="text-2xl font-bold text-purple-600">{info.totalCategories}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
)}
{info.totalTags !== undefined && (
<div>
<div className="text-2xl font-bold text-indigo-600">{info.totalTags}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
)}
{info.totalViews !== undefined && (
<div>
<div className="text-2xl font-bold text-red-600">{info.totalViews?.toLocaleString()}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
)}
</div>
</div>
)}
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="all_blogs">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="选择视图" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all_blogs"></SelectItem>
<SelectItem value="published_blogs"></SelectItem>
<SelectItem value="draft_blogs">稿</SelectItem>
<SelectItem value="archived_blogs"></SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="all_blogs"></TabsTrigger>
<TabsTrigger value="published_blogs">
<Badge variant="secondary">{info?.totalPublished}</Badge>
</TabsTrigger>
<TabsTrigger value="draft_blogs">
稿 <Badge variant="secondary">{info?.totalDrafts}</Badge>
</TabsTrigger>
<TabsTrigger value="archived_blogs"> <Badge variant="secondary">{info?.totalArchived}</Badge></TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.location.href = '/admin/editor'}
>
<IconPlus />
<span className="hidden lg:inline"></span>
</Button>
</div>
</div>
<TabsContent value="all_blogs" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<BlogDataTable
data={blogs}
isLoading={loading}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={totalCount}
onFilterChange={() => setFilter(undefined)}
updateBlog={updateBlog}
deleteBlog={deleteBlog}
/>
</TabsContent>
<TabsContent value="published_blogs" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<BlogDataTable
data={filter === "published" ? blogs : []}
isLoading={loading && filter === "published"}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={filter === "published" ? totalCount : 0}
onFilterChange={() => setFilter("published")}
updateBlog={updateBlog}
deleteBlog={deleteBlog}
/>
</TabsContent>
<TabsContent value="draft_blogs" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<BlogDataTable
data={filter === "draft" ? blogs : []}
isLoading={loading && filter === "draft"}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={filter === "draft" ? totalCount : 0}
onFilterChange={() => setFilter("draft")}
updateBlog={updateBlog}
deleteBlog={deleteBlog}
/>
</TabsContent>
<TabsContent value="archived_blogs" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<BlogDataTable
data={filter === "archived" ? blogs : []}
isLoading={loading && filter === "archived"}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={filter === "archived" ? totalCount : 0}
onFilterChange={() => setFilter("archived")}
updateBlog={updateBlog}
deleteBlog={deleteBlog}
/>
</TabsContent>
</Tabs>
</div>
)
}
function BlogDataTable({
data,
isLoading = false,
handleDragEnd,
pagination,
setPagination,
sorting,
setSorting,
totalCount,
onFilterChange,
updateBlog,
deleteBlog
}: {
data: any[]
isLoading?: boolean
handleDragEnd: (event: DragEndEvent) => void
pagination: { pageIndex: number; pageSize: number }
setPagination: React.Dispatch<React.SetStateAction<{ pageIndex: number; pageSize: number }>>
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
totalCount: number
onFilterChange: () => void
updateBlog: any
deleteBlog: any
}) {
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
// Call onFilterChange when component mounts to ensure proper filter is set
React.useEffect(() => {
onFilterChange()
}, [onFilterChange])
function handleLocalDragEnd(event: DragEndEvent) {
handleDragEnd(event)
}
const sortableId = React.useId()
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
const table = useReactTable({
data: data || [],
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
pageCount: Math.ceil(totalCount / pagination.pageSize),
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
manualPagination: true,
manualSorting: true,
meta: {
updateBlog,
deleteBlog,
},
})
return (
<>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleLocalDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center">
...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
<SortableContext
items={data?.map((blog: any) => blog.id) || []}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length}
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
{table.getState().pagination.pageIndex + 1} {table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</>
)
}
function BlogCellViewer({ item }: { item: z.infer<typeof blogSchema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<div className="flex flex-col gap-1">
<Button variant="link" className="text-foreground w-fit px-0 text-left justify-start">
{item.title}
</Button>
{item.excerpt && (
<span className="text-xs text-muted-foreground line-clamp-2 max-w-60">
{item.excerpt}
</span>
)}
</div>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.title}</DrawerTitle>
<DrawerDescription>
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="title"></Label>
<Input id="title" defaultValue={item.title} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="slug"></Label>
<Input id="slug" defaultValue={item.slug} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="excerpt"></Label>
<Input id="excerpt" defaultValue={item.excerpt} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="status"></Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="published"></SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="viewCount"></Label>
<Input id="viewCount" defaultValue={item.viewCount.toString()} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="featured"></Label>
<Select defaultValue={item.isFeatured ? "true" : "false"}>
<SelectTrigger id="featured" className="w-full">
<SelectValue placeholder="选择推荐状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true"></SelectItem>
<SelectItem value="false"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="active"></Label>
<Select defaultValue={item.isActive ? "true" : "false"}>
<SelectTrigger id="active" className="w-full">
<SelectValue placeholder="选择激活状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true"></SelectItem>
<SelectItem value="false"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</form>
</div>
<DrawerFooter>
<Button
onClick={() => {
window.location.href = `/admin/editor?id=${item.id}`
}}
>
</Button>
<DrawerClose asChild>
<Button variant="outline"></Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}