diff --git a/CLAUDE.md b/CLAUDE.md index fa4a8f8..01fcdcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Production server**: `npm run start` - Starts production server (requires build first) - **Lint**: `npm run lint` - Runs Next.js ESLint checks +**Note**: No test framework is currently configured in this project. + ## High-Level Architecture This is a Next.js 15 application built with React 19 that creates an interactive map visualization with a custom WebGL timeline component. @@ -35,6 +37,17 @@ This is a Next.js 15 application built with React 19 that creates an interactive - shadcn/ui component library in `components/ui/` - Sidebar layout using `components/ui/sidebar.tsx` +**Admin System**: +- Comprehensive admin interface in `app/admin/` with dashboard, analytics, user management, and content editor +- Admin pages include dynamic configuration system and navigation components +- Uses both Apollo Client and GraphQL Request for data fetching + +**Data Layer**: +- GraphQL API integration using Apollo Client and GraphQL Request +- Backend communication via `/api/bff` endpoint (configured in `lib/gr-client.ts`) +- Page-based content management with block-based architecture +- Authentication support with JWT tokens + ### Key Technical Details **WebGL Timeline**: @@ -55,17 +68,26 @@ This is a Next.js 15 application built with React 19 that creates an interactive **Build Configuration**: - Next.js 15 with App Router -- Custom webpack config for GLSL file loading via `raw-loader` +- Custom webpack config for GLSL file loading via `raw-loader` (see `next.config.ts`) - TypeScript with strict mode enabled - Absolute imports using `@/*` path mapping +**GraphQL Integration**: +- Uses both Apollo Client (`@apollo/client`) and GraphQL Request (`graphql-request`) for different use cases +- Page content fetching via `lib/fetchers.ts` with support for authentication +- Block-based content architecture supporting TextBlock, ChartBlock, SettingsBlock, and HeroBlock types +- Environment variables: `GRAPHQL_BACKEND_URL` and `NEXTAUTH_URL` + ### File Structure Notes - `app/` - Next.js App Router pages and components + - `app/admin/` - Complete admin interface with dashboard, analytics, users, and content editor + - `app/glsl/timeline/` - Custom GLSL shaders for WebGL timeline rendering - `components/` - Reusable React components -- `hooks/` - Custom React hooks -- `lib/` - Utility functions + - `components/ui/` - shadcn/ui component library +- `hooks/` - Custom React hooks for map, timeline, and mobile detection +- `lib/` - Utility functions including GraphQL clients and data fetchers - `types/` - TypeScript type definitions - `public/` - Static assets -The application combines modern web mapping with custom WebGL visualization to create an interactive timeline-driven map interface. \ No newline at end of file +The application combines modern web mapping with custom WebGL visualization to create an interactive timeline-driven map interface, complemented by a full-featured admin system for content management. \ No newline at end of file diff --git a/app/admin/blogs/blog-table.tsx b/app/admin/blogs/blog-table.tsx new file mode 100644 index 0000000..5e2bf22 --- /dev/null +++ b/app/admin/blogs/blog-table.tsx @@ -0,0 +1,1166 @@ +"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 ( + + ) +} + +const columns: ColumnDef>[] = [ + { + id: "drag", + header: () => null, + cell: ({ row }) => , + }, + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "title", + header: "标题", + cell: ({ row }) => { + return + }, + enableHiding: false, + }, + { + accessorKey: "status", + header: "状态", + cell: ({ row }) => { + const status = row.original.status; + const statusMap: Record = { + draft: { label: "草稿", variant: "secondary" }, + published: { label: "已发布", variant: "default" }, + archived: { label: "已归档", variant: "outline" }, + }; + + const statusInfo = statusMap[status] || { label: status, variant: "outline" }; + + return ( +
+ + {statusInfo.label} + +
+ ); + }, + }, + { + accessorKey: "viewCount", + header: "浏览量", + cell: ({ row }) => ( +
+ + {row.original.viewCount} + +
+ ), + }, + { + accessorKey: "isFeatured", + header: "推荐", + cell: ({ row }) => ( +
+ {row.original.isFeatured && ( + + 推荐 + + )} +
+ ), + }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ) + }, + 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 ( +
+
+ + {date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + + {timeAgo} + +
+
+ ); + }, + 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 ( + + ) + }, + 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 ( +
+
+ + {date.toLocaleDateString('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + + + {timeAgo} + +
+
+ ); + }, + 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 ( + + + + + + { + window.location.href = `/admin/editor?id=${row.original.id}` + }} + > + 编辑 + + 预览 + {row.original.status === "draft" && ( + { + updateBlogMutation({ + variables: { + id: row.original.id, + input: { status: "published" } + } + }) + }} + > + 发布 + + )} + {row.original.status === "published" && ( + { + updateBlogMutation({ + variables: { + id: row.original.id, + input: { status: "archived" } + } + }) + }} + > + 归档 + + )} + { + updateBlogMutation({ + variables: { + id: row.original.id, + input: { isFeatured: !row.original.isFeatured } + } + }) + }} + > + {row.original.isFeatured ? "取消推荐" : "设为推荐"} + + { + updateBlogMutation({ + variables: { + id: row.original.id, + input: { isActive: !row.original.isActive } + } + }) + }} + > + {row.original.isActive ? "停用" : "启用"} + + + { + if (confirm('确定要删除这篇博客吗?此操作不可撤销。')) { + deleteBlogMutation({ + variables: { + id: row.original.id + } + }) + } + }} + > + 删除 + + + + ) + }, + }, +] + +function DraggableRow({ row }: { row: Row> }) { + const { transform, transition, setNodeRef, isDragging } = useSortable({ + id: row.original.id, + }) + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +} + +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([]) + const [filter, setFilter] = React.useState() + + 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 &&
{typeof error === 'string' ? error : 'An error occurred'}
} + + + ) +} + +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> + sorting: SortingState + setSorting: React.Dispatch> + filter?: string + setFilter: React.Dispatch> + totalCount: number + updateBlog: any + deleteBlog: any +}) { + return ( +
+ + {/* Blog Stats Section */} + {info && ( +
+
+
+
{info.totalBlogs}
+
总博客
+
+
+
{info.totalPublished}
+
已发布
+
+
+
{info.totalDrafts}
+
草稿
+
+
+
{info.totalArchived}
+
已归档
+
+ {info.totalCategories !== undefined && ( +
+
{info.totalCategories}
+
分类
+
+ )} + {info.totalTags !== undefined && ( +
+
{info.totalTags}
+
标签
+
+ )} + {info.totalViews !== undefined && ( +
+
{info.totalViews?.toLocaleString()}
+
总浏览
+
+ )} +
+
+ )} + +
+ + + + 所有博客 + + 已发布 {info?.totalPublished} + + + 草稿 {info?.totalDrafts} + + 已归档 {info?.totalArchived} + +
+ +
+
+ + + setFilter(undefined)} + updateBlog={updateBlog} + deleteBlog={deleteBlog} + /> + + + + setFilter("published")} + updateBlog={updateBlog} + deleteBlog={deleteBlog} + /> + + + + setFilter("draft")} + updateBlog={updateBlog} + deleteBlog={deleteBlog} + /> + + + + setFilter("archived")} + updateBlog={updateBlog} + deleteBlog={deleteBlog} + /> + +
+
+ ) +} + +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> + sorting: SortingState + setSorting: React.Dispatch> + totalCount: number + onFilterChange: () => void + updateBlog: any + deleteBlog: any +}) { + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = React.useState({}) + const [columnFilters, setColumnFilters] = React.useState([]) + + // 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 ( + <> +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {isLoading ? ( + + + 加载中... + + + ) : table.getRowModel().rows?.length ? ( + blog.id) || []} + strategy={verticalListSortingStrategy} + > + {table.getRowModel().rows.map((row) => ( + + ))} + + ) : ( + + + 暂无数据。 + + + )} + +
+
+
+
+
+ 已选择 {table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length} 行。 +
+
+
+ + +
+
+ 第 {table.getState().pagination.pageIndex + 1} 页,共 {table.getPageCount()} 页 +
+
+ + + + +
+
+
+ + ) +} + +function BlogCellViewer({ item }: { item: z.infer }) { + const isMobile = useIsMobile() + + return ( + + +
+ + {item.excerpt && ( + + {item.excerpt} + + )} +
+
+ + + + {item.title} + + 博客详情信息 + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/app/admin/blogs/create-blog-form.tsx b/app/admin/blogs/create-blog-form.tsx new file mode 100644 index 0000000..1b99280 --- /dev/null +++ b/app/admin/blogs/create-blog-form.tsx @@ -0,0 +1,336 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { z } from "zod" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useEffect, useState, useCallback } from "react" +import { gql, useMutation } from "@apollo/client" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { DialogClose, DialogFooter } from "@/components/ui/dialog" +import { IconLoader } from "@tabler/icons-react" + +const CREATE_BLOG = gql` + mutation CreateBlog($input: CreateBlogInput!) { + createBlog(input: $input) { + id + title + slug + status + } + } +` + +const schema = z.object({ + title: z.string().min(1, "标题不能为空"), + slug: z.string().min(1, "链接不能为空"), + excerpt: z.string().optional(), + content: z.string().min(1, "内容不能为空"), + status: z.enum(["draft", "published", "archived"]), + metaTitle: z.string().optional(), + metaDescription: z.string().optional(), + isFeatured: z.boolean().default(false), + isActive: z.boolean().default(true), +}) + +export default function CreateBlogForm() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const [createBlog] = useMutation(CREATE_BLOG) + + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { + title: "", + slug: "", + excerpt: "", + content: "", + status: "draft" as const, + metaTitle: "", + metaDescription: "", + isFeatured: false, + isActive: true, + }, + }) + + // 生成slug + const generateSlug = useCallback((title: string) => { + return title + .toLowerCase() + .replace(/[^\w\s-]/g, '') // 移除特殊字符 + .replace(/\s+/g, '-') // 空格替换为连字符 + .replace(/-+/g, '-') // 多个连字符合并为一个 + .trim(); + }, []); + + // 监听标题变化自动生成slug + const watchedTitle = form.watch("title"); + useEffect(() => { + if (watchedTitle) { + const slug = generateSlug(watchedTitle); + form.setValue("slug", slug); + } + }, [watchedTitle, generateSlug, form]); + + async function onSubmit(values: z.infer) { + try { + setIsLoading(true); + setError(null); + + await createBlog({ + variables: { + input: { + title: values.title, + slug: values.slug, + content: JSON.stringify(values.content), // Convert to JSON + status: values.status, + excerpt: values.excerpt || null, + metaTitle: values.metaTitle || null, + metaDescription: values.metaDescription || null, + isFeatured: values.isFeatured, + isActive: values.isActive, + } + } + }); + + // 重置表单 + form.reset(); + + } catch (err) { + setError(err instanceof Error ? err.message : '创建博客失败,请重试'); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + {error && ( +
+ {error} +
+ )} +
+
+ ( + + 标题 * + + + + + + )} /> +
+ +
+ ( + + 链接 * + + + + + + )} /> +
+ +
+ ( + + 摘要 + +