From 1eca50c6ce75d75c0c054559efa9ef496a9b44e2 Mon Sep 17 00:00:00 2001 From: Tsuki Date: Mon, 28 Jul 2025 07:25:32 +0800 Subject: [PATCH] me and admin --- app/admin/analytics/page.tsx | 14 + app/admin/chart-area-interactive.tsx | 292 +++++ app/admin/dashboard/page.tsx | 14 + app/admin/data-table.tsx | 807 +++++++++++++ app/admin/data.json | 614 ++++++++++ app/admin/layout.tsx | 26 + app/admin/nav-documents.tsx | 92 ++ app/admin/nav-main.tsx | 67 ++ app/admin/nav-secondary.tsx | 42 + app/admin/nav-user.tsx | 110 ++ app/admin/page.tsx | 6 + app/admin/section-cards.tsx | 102 ++ app/admin/sidebar.tsx | 169 +++ app/admin/site-header.tsx | 30 + app/admin/users/create-user-form.tsx | 262 +++++ app/admin/users/page.tsx | 17 + app/admin/users/user-table.tsx | 832 ++++++++++++++ app/app-sidebar.tsx | 6 +- app/client-provider.tsx | 27 + app/glsl/layers/triangle_frag.glsl | 6 + app/glsl/layers/triangle_vert.glsl | 8 + app/layout.tsx | 19 +- app/login/login-form.tsx | 132 +++ app/login/page.tsx | 76 ++ app/map-context.tsx | 21 + app/me/layout.tsx | 25 + app/me/nav-main.tsx | 67 ++ app/me/page.tsx | 21 + app/me/sidebar.tsx | 164 +++ app/nav-user.tsx | 15 +- app/page.tsx | 26 +- app/timeline.tsx | 32 + app/user-context.tsx | 416 +++++++ components/colorbar.tsx | 133 +++ components/map-component.tsx | 183 +-- components/ui/chart.tsx | 353 ++++++ components/ui/checkbox.tsx | 32 + components/ui/dialog.tsx | 143 +++ components/ui/drawer.tsx | 149 ++- components/ui/form.tsx | 167 +++ components/ui/table.tsx | 116 ++ components/ui/tabs.tsx | 66 ++ components/ui/toggle-group.tsx | 73 ++ components/ui/toggle.tsx | 47 + hooks/use-radartile.ts | 32 +- lib/apollo-client.ts | 37 + lib/color-maps.ts | 225 ++++ lib/tile-mesh.ts | 2 +- package-lock.json | 1563 +++++++++++++++++++++++++- package.json | 22 +- types/user.ts | 34 + 51 files changed, 7754 insertions(+), 180 deletions(-) create mode 100644 app/admin/analytics/page.tsx create mode 100644 app/admin/chart-area-interactive.tsx create mode 100644 app/admin/dashboard/page.tsx create mode 100644 app/admin/data-table.tsx create mode 100644 app/admin/data.json create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/nav-documents.tsx create mode 100644 app/admin/nav-main.tsx create mode 100644 app/admin/nav-secondary.tsx create mode 100644 app/admin/nav-user.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/section-cards.tsx create mode 100644 app/admin/sidebar.tsx create mode 100644 app/admin/site-header.tsx create mode 100644 app/admin/users/create-user-form.tsx create mode 100644 app/admin/users/page.tsx create mode 100644 app/admin/users/user-table.tsx create mode 100644 app/client-provider.tsx create mode 100644 app/glsl/layers/triangle_frag.glsl create mode 100644 app/glsl/layers/triangle_vert.glsl create mode 100644 app/login/login-form.tsx create mode 100644 app/login/page.tsx create mode 100644 app/me/layout.tsx create mode 100644 app/me/nav-main.tsx create mode 100644 app/me/page.tsx create mode 100644 app/me/sidebar.tsx create mode 100644 app/user-context.tsx create mode 100644 components/colorbar.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 lib/apollo-client.ts create mode 100644 lib/color-maps.ts create mode 100644 types/user.ts diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx new file mode 100644 index 0000000..9b16337 --- /dev/null +++ b/app/admin/analytics/page.tsx @@ -0,0 +1,14 @@ +"use client" + + +export default function Page() { + return ( +
+
+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/app/admin/chart-area-interactive.tsx b/app/admin/chart-area-interactive.tsx new file mode 100644 index 0000000..3b9bee9 --- /dev/null +++ b/app/admin/chart-area-interactive.tsx @@ -0,0 +1,292 @@ +"use client" + +import * as React from "react" +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" + +import { useIsMobile } from "@/hooks/use-mobile" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ToggleGroup, + ToggleGroupItem, +} from "@/components/ui/toggle-group" + +export const description = "An interactive area chart" + +const chartData = [ + { date: "2024-04-01", desktop: 222, mobile: 150 }, + { date: "2024-04-02", desktop: 97, mobile: 180 }, + { date: "2024-04-03", desktop: 167, mobile: 120 }, + { date: "2024-04-04", desktop: 242, mobile: 260 }, + { date: "2024-04-05", desktop: 373, mobile: 290 }, + { date: "2024-04-06", desktop: 301, mobile: 340 }, + { date: "2024-04-07", desktop: 245, mobile: 180 }, + { date: "2024-04-08", desktop: 409, mobile: 320 }, + { date: "2024-04-09", desktop: 59, mobile: 110 }, + { date: "2024-04-10", desktop: 261, mobile: 190 }, + { date: "2024-04-11", desktop: 327, mobile: 350 }, + { date: "2024-04-12", desktop: 292, mobile: 210 }, + { date: "2024-04-13", desktop: 342, mobile: 380 }, + { date: "2024-04-14", desktop: 137, mobile: 220 }, + { date: "2024-04-15", desktop: 120, mobile: 170 }, + { date: "2024-04-16", desktop: 138, mobile: 190 }, + { date: "2024-04-17", desktop: 446, mobile: 360 }, + { date: "2024-04-18", desktop: 364, mobile: 410 }, + { date: "2024-04-19", desktop: 243, mobile: 180 }, + { date: "2024-04-20", desktop: 89, mobile: 150 }, + { date: "2024-04-21", desktop: 137, mobile: 200 }, + { date: "2024-04-22", desktop: 224, mobile: 170 }, + { date: "2024-04-23", desktop: 138, mobile: 230 }, + { date: "2024-04-24", desktop: 387, mobile: 290 }, + { date: "2024-04-25", desktop: 215, mobile: 250 }, + { date: "2024-04-26", desktop: 75, mobile: 130 }, + { date: "2024-04-27", desktop: 383, mobile: 420 }, + { date: "2024-04-28", desktop: 122, mobile: 180 }, + { date: "2024-04-29", desktop: 315, mobile: 240 }, + { date: "2024-04-30", desktop: 454, mobile: 380 }, + { date: "2024-05-01", desktop: 165, mobile: 220 }, + { date: "2024-05-02", desktop: 293, mobile: 310 }, + { date: "2024-05-03", desktop: 247, mobile: 190 }, + { date: "2024-05-04", desktop: 385, mobile: 420 }, + { date: "2024-05-05", desktop: 481, mobile: 390 }, + { date: "2024-05-06", desktop: 498, mobile: 520 }, + { date: "2024-05-07", desktop: 388, mobile: 300 }, + { date: "2024-05-08", desktop: 149, mobile: 210 }, + { date: "2024-05-09", desktop: 227, mobile: 180 }, + { date: "2024-05-10", desktop: 293, mobile: 330 }, + { date: "2024-05-11", desktop: 335, mobile: 270 }, + { date: "2024-05-12", desktop: 197, mobile: 240 }, + { date: "2024-05-13", desktop: 197, mobile: 160 }, + { date: "2024-05-14", desktop: 448, mobile: 490 }, + { date: "2024-05-15", desktop: 473, mobile: 380 }, + { date: "2024-05-16", desktop: 338, mobile: 400 }, + { date: "2024-05-17", desktop: 499, mobile: 420 }, + { date: "2024-05-18", desktop: 315, mobile: 350 }, + { date: "2024-05-19", desktop: 235, mobile: 180 }, + { date: "2024-05-20", desktop: 177, mobile: 230 }, + { date: "2024-05-21", desktop: 82, mobile: 140 }, + { date: "2024-05-22", desktop: 81, mobile: 120 }, + { date: "2024-05-23", desktop: 252, mobile: 290 }, + { date: "2024-05-24", desktop: 294, mobile: 220 }, + { date: "2024-05-25", desktop: 201, mobile: 250 }, + { date: "2024-05-26", desktop: 213, mobile: 170 }, + { date: "2024-05-27", desktop: 420, mobile: 460 }, + { date: "2024-05-28", desktop: 233, mobile: 190 }, + { date: "2024-05-29", desktop: 78, mobile: 130 }, + { date: "2024-05-30", desktop: 340, mobile: 280 }, + { date: "2024-05-31", desktop: 178, mobile: 230 }, + { date: "2024-06-01", desktop: 178, mobile: 200 }, + { date: "2024-06-02", desktop: 470, mobile: 410 }, + { date: "2024-06-03", desktop: 103, mobile: 160 }, + { date: "2024-06-04", desktop: 439, mobile: 380 }, + { date: "2024-06-05", desktop: 88, mobile: 140 }, + { date: "2024-06-06", desktop: 294, mobile: 250 }, + { date: "2024-06-07", desktop: 323, mobile: 370 }, + { date: "2024-06-08", desktop: 385, mobile: 320 }, + { date: "2024-06-09", desktop: 438, mobile: 480 }, + { date: "2024-06-10", desktop: 155, mobile: 200 }, + { date: "2024-06-11", desktop: 92, mobile: 150 }, + { date: "2024-06-12", desktop: 492, mobile: 420 }, + { date: "2024-06-13", desktop: 81, mobile: 130 }, + { date: "2024-06-14", desktop: 426, mobile: 380 }, + { date: "2024-06-15", desktop: 307, mobile: 350 }, + { date: "2024-06-16", desktop: 371, mobile: 310 }, + { date: "2024-06-17", desktop: 475, mobile: 520 }, + { date: "2024-06-18", desktop: 107, mobile: 170 }, + { date: "2024-06-19", desktop: 341, mobile: 290 }, + { date: "2024-06-20", desktop: 408, mobile: 450 }, + { date: "2024-06-21", desktop: 169, mobile: 210 }, + { date: "2024-06-22", desktop: 317, mobile: 270 }, + { date: "2024-06-23", desktop: 480, mobile: 530 }, + { date: "2024-06-24", desktop: 132, mobile: 180 }, + { date: "2024-06-25", desktop: 141, mobile: 190 }, + { date: "2024-06-26", desktop: 434, mobile: 380 }, + { date: "2024-06-27", desktop: 448, mobile: 490 }, + { date: "2024-06-28", desktop: 149, mobile: 200 }, + { date: "2024-06-29", desktop: 103, mobile: 160 }, + { date: "2024-06-30", desktop: 446, mobile: 400 }, +] + +const chartConfig = { + visitors: { + label: "Visitors", + }, + desktop: { + label: "Desktop", + color: "var(--primary)", + }, + mobile: { + label: "Mobile", + color: "var(--primary)", + }, +} satisfies ChartConfig + +export function ChartAreaInteractive() { + const isMobile = useIsMobile() + const [timeRange, setTimeRange] = React.useState("90d") + + React.useEffect(() => { + if (isMobile) { + setTimeRange("7d") + } + }, [isMobile]) + + const filteredData = chartData.filter((item) => { + const date = new Date(item.date) + const referenceDate = new Date("2024-06-30") + let daysToSubtract = 90 + if (timeRange === "30d") { + daysToSubtract = 30 + } else if (timeRange === "7d") { + daysToSubtract = 7 + } + const startDate = new Date(referenceDate) + startDate.setDate(startDate.getDate() - daysToSubtract) + return date >= startDate + }) + + return ( + + + Total Visitors + + + Total for the last 3 months + + Last 3 months + + + + Last 3 months + Last 30 days + Last 7 days + + + + + + + + + + + + + + + + + + + { + const date = new Date(value) + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + /> + { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + indicator="dot" + /> + } + /> + + + + + + + ) +} diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..9b16337 --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -0,0 +1,14 @@ +"use client" + + +export default function Page() { + return ( +
+
+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/app/admin/data-table.tsx b/app/admin/data-table.tsx new file mode 100644 index 0000000..54e7ffd --- /dev/null +++ b/app/admin/data-table.tsx @@ -0,0 +1,807 @@ +"use client" + +import * as React from "react" +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type UniqueIdentifier, +} 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, + IconCircleCheckFilled, + IconDotsVertical, + IconGripVertical, + IconLayoutColumns, + IconLoader, + 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 { Area, AreaChart, CartesianGrid, XAxis } from "recharts" +import { toast } from "sonner" +import { z } from "zod" + +import { useIsMobile } from "@/hooks/use-mobile" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +import { Checkbox } from "@/components/ui/checkbox" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + 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 { Separator } from "@/components/ui/separator" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" + +export const schema = z.object({ + id: z.number(), + header: z.string(), + type: z.string(), + status: z.string(), + target: z.string(), + limit: z.string(), + reviewer: z.string(), +}) + +// Create a separate component for the drag handle +function DragHandle({ id }: { id: number }) { + 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: "header", + header: "Header", + cell: ({ row }) => { + return + }, + enableHiding: false, + }, + { + accessorKey: "type", + header: "Section Type", + cell: ({ row }) => ( +
+ + {row.original.type} + +
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => ( + + {row.original.status === "Done" ? ( + + ) : ( + + )} + {row.original.status} + + ), + }, + { + accessorKey: "target", + header: () =>
Target
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "limit", + header: () =>
Limit
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "reviewer", + header: "Reviewer", + cell: ({ row }) => { + const isAssigned = row.original.reviewer !== "Assign reviewer" + + if (isAssigned) { + return row.original.reviewer + } + + return ( + <> + + + + ) + }, + }, + { + id: "actions", + cell: () => ( + + + + + + Edit + Make a copy + Favorite + + Delete + + + ), + }, +] + +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())} + + ))} + + ) +} + +export function DataTable({ + data: initialData, +}: { + data: z.infer[] +}) { + const [data, setData] = React.useState(() => initialData) + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = + React.useState({}) + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + const [sorting, setSorting] = React.useState([]) + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + const sortableId = React.useId() + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ) + + const dataIds = React.useMemo( + () => data?.map(({ id }) => id) || [], + [data] + ) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination, + }, + 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(), + }) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (active && over && active.id !== over.id) { + setData((data) => { + const oldIndex = dataIds.indexOf(active.id) + const newIndex = dataIds.indexOf(over.id) + return arrayMove(data, oldIndex, newIndex) + }) + } + } + + return ( + +
+ + + + Outline + + Past Performance 3 + + + Key Personnel 2 + + Focus Documents + +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + + +
+
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + + {table.getRowModel().rows.map((row) => ( + + ))} + + ) : ( + + + No results. + + + )} + +
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ ) +} + +const chartData = [ + { month: "January", desktop: 186, mobile: 80 }, + { month: "February", desktop: 305, mobile: 200 }, + { month: "March", desktop: 237, mobile: 120 }, + { month: "April", desktop: 73, mobile: 190 }, + { month: "May", desktop: 209, mobile: 130 }, + { month: "June", desktop: 214, mobile: 140 }, +] + +const chartConfig = { + desktop: { + label: "Desktop", + color: "var(--primary)", + }, + mobile: { + label: "Mobile", + color: "var(--primary)", + }, +} satisfies ChartConfig + +function TableCellViewer({ item }: { item: z.infer }) { + const isMobile = useIsMobile() + + return ( + + + + + + + {item.header} + + Showing total visitors for the last 6 months + + +
+ {!isMobile && ( + <> + + + + value.slice(0, 3)} + hide + /> + } + /> + + + + + +
+
+ Trending up by 5.2% this month{" "} + +
+
+ Showing total visitors for the last 6 months. This is just + some random text to test the layout. It spans multiple lines + and should wrap around. +
+
+ + + )} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + + + + +
+
+ ) +} diff --git a/app/admin/data.json b/app/admin/data.json new file mode 100644 index 0000000..13ff036 --- /dev/null +++ b/app/admin/data.json @@ -0,0 +1,614 @@ +[ + { + "id": 1, + "header": "Cover page", + "type": "Cover page", + "status": "In Process", + "target": "18", + "limit": "5", + "reviewer": "Eddie Lake" + }, + { + "id": 2, + "header": "Table of contents", + "type": "Table of contents", + "status": "Done", + "target": "29", + "limit": "24", + "reviewer": "Eddie Lake" + }, + { + "id": 3, + "header": "Executive summary", + "type": "Narrative", + "status": "Done", + "target": "10", + "limit": "13", + "reviewer": "Eddie Lake" + }, + { + "id": 4, + "header": "Technical approach", + "type": "Narrative", + "status": "Done", + "target": "27", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 5, + "header": "Design", + "type": "Narrative", + "status": "In Process", + "target": "2", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 6, + "header": "Capabilities", + "type": "Narrative", + "status": "In Process", + "target": "20", + "limit": "8", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 7, + "header": "Integration with existing systems", + "type": "Narrative", + "status": "In Process", + "target": "19", + "limit": "21", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 8, + "header": "Innovation and Advantages", + "type": "Narrative", + "status": "Done", + "target": "25", + "limit": "26", + "reviewer": "Assign reviewer" + }, + { + "id": 9, + "header": "Overview of EMR's Innovative Solutions", + "type": "Technical content", + "status": "Done", + "target": "7", + "limit": "23", + "reviewer": "Assign reviewer" + }, + { + "id": 10, + "header": "Advanced Algorithms and Machine Learning", + "type": "Narrative", + "status": "Done", + "target": "30", + "limit": "28", + "reviewer": "Assign reviewer" + }, + { + "id": 11, + "header": "Adaptive Communication Protocols", + "type": "Narrative", + "status": "Done", + "target": "9", + "limit": "31", + "reviewer": "Assign reviewer" + }, + { + "id": 12, + "header": "Advantages Over Current Technologies", + "type": "Narrative", + "status": "Done", + "target": "12", + "limit": "0", + "reviewer": "Assign reviewer" + }, + { + "id": 13, + "header": "Past Performance", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "33", + "reviewer": "Assign reviewer" + }, + { + "id": 14, + "header": "Customer Feedback and Satisfaction Levels", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "34", + "reviewer": "Assign reviewer" + }, + { + "id": 15, + "header": "Implementation Challenges and Solutions", + "type": "Narrative", + "status": "Done", + "target": "3", + "limit": "35", + "reviewer": "Assign reviewer" + }, + { + "id": 16, + "header": "Security Measures and Data Protection Policies", + "type": "Narrative", + "status": "In Process", + "target": "6", + "limit": "36", + "reviewer": "Assign reviewer" + }, + { + "id": 17, + "header": "Scalability and Future Proofing", + "type": "Narrative", + "status": "Done", + "target": "4", + "limit": "37", + "reviewer": "Assign reviewer" + }, + { + "id": 18, + "header": "Cost-Benefit Analysis", + "type": "Plain language", + "status": "Done", + "target": "14", + "limit": "38", + "reviewer": "Assign reviewer" + }, + { + "id": 19, + "header": "User Training and Onboarding Experience", + "type": "Narrative", + "status": "Done", + "target": "17", + "limit": "39", + "reviewer": "Assign reviewer" + }, + { + "id": 20, + "header": "Future Development Roadmap", + "type": "Narrative", + "status": "Done", + "target": "11", + "limit": "40", + "reviewer": "Assign reviewer" + }, + { + "id": 21, + "header": "System Architecture Overview", + "type": "Technical content", + "status": "In Process", + "target": "24", + "limit": "18", + "reviewer": "Maya Johnson" + }, + { + "id": 22, + "header": "Risk Management Plan", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "22", + "reviewer": "Carlos Rodriguez" + }, + { + "id": 23, + "header": "Compliance Documentation", + "type": "Legal", + "status": "In Process", + "target": "31", + "limit": "27", + "reviewer": "Sarah Chen" + }, + { + "id": 24, + "header": "API Documentation", + "type": "Technical content", + "status": "Done", + "target": "8", + "limit": "12", + "reviewer": "Raj Patel" + }, + { + "id": 25, + "header": "User Interface Mockups", + "type": "Visual", + "status": "In Process", + "target": "19", + "limit": "25", + "reviewer": "Leila Ahmadi" + }, + { + "id": 26, + "header": "Database Schema", + "type": "Technical content", + "status": "Done", + "target": "22", + "limit": "20", + "reviewer": "Thomas Wilson" + }, + { + "id": 27, + "header": "Testing Methodology", + "type": "Technical content", + "status": "In Process", + "target": "17", + "limit": "14", + "reviewer": "Assign reviewer" + }, + { + "id": 28, + "header": "Deployment Strategy", + "type": "Narrative", + "status": "Done", + "target": "26", + "limit": "30", + "reviewer": "Eddie Lake" + }, + { + "id": 29, + "header": "Budget Breakdown", + "type": "Financial", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 30, + "header": "Market Analysis", + "type": "Research", + "status": "Done", + "target": "29", + "limit": "32", + "reviewer": "Sophia Martinez" + }, + { + "id": 31, + "header": "Competitor Comparison", + "type": "Research", + "status": "In Process", + "target": "21", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 32, + "header": "Maintenance Plan", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "23", + "reviewer": "Alex Thompson" + }, + { + "id": 33, + "header": "User Personas", + "type": "Research", + "status": "In Process", + "target": "27", + "limit": "24", + "reviewer": "Nina Patel" + }, + { + "id": 34, + "header": "Accessibility Compliance", + "type": "Legal", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 35, + "header": "Performance Metrics", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "David Kim" + }, + { + "id": 36, + "header": "Disaster Recovery Plan", + "type": "Technical content", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 37, + "header": "Third-party Integrations", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Eddie Lake" + }, + { + "id": 38, + "header": "User Feedback Summary", + "type": "Research", + "status": "Done", + "target": "20", + "limit": "15", + "reviewer": "Assign reviewer" + }, + { + "id": 39, + "header": "Localization Strategy", + "type": "Narrative", + "status": "In Process", + "target": "12", + "limit": "19", + "reviewer": "Maria Garcia" + }, + { + "id": 40, + "header": "Mobile Compatibility", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "James Wilson" + }, + { + "id": 41, + "header": "Data Migration Plan", + "type": "Technical content", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Assign reviewer" + }, + { + "id": 42, + "header": "Quality Assurance Protocols", + "type": "Technical content", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Priya Singh" + }, + { + "id": 43, + "header": "Stakeholder Analysis", + "type": "Research", + "status": "In Process", + "target": "11", + "limit": "14", + "reviewer": "Eddie Lake" + }, + { + "id": 44, + "header": "Environmental Impact Assessment", + "type": "Research", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Assign reviewer" + }, + { + "id": 45, + "header": "Intellectual Property Rights", + "type": "Legal", + "status": "In Process", + "target": "17", + "limit": "20", + "reviewer": "Sarah Johnson" + }, + { + "id": 46, + "header": "Customer Support Framework", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 47, + "header": "Version Control Strategy", + "type": "Technical content", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 48, + "header": "Continuous Integration Pipeline", + "type": "Technical content", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Michael Chen" + }, + { + "id": 49, + "header": "Regulatory Compliance", + "type": "Legal", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Assign reviewer" + }, + { + "id": 50, + "header": "User Authentication System", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "Eddie Lake" + }, + { + "id": 51, + "header": "Data Analytics Framework", + "type": "Technical content", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 52, + "header": "Cloud Infrastructure", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 53, + "header": "Network Security Measures", + "type": "Technical content", + "status": "In Process", + "target": "29", + "limit": "32", + "reviewer": "Lisa Wong" + }, + { + "id": 54, + "header": "Project Timeline", + "type": "Planning", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Eddie Lake" + }, + { + "id": 55, + "header": "Resource Allocation", + "type": "Planning", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Assign reviewer" + }, + { + "id": 56, + "header": "Team Structure and Roles", + "type": "Planning", + "status": "Done", + "target": "20", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 57, + "header": "Communication Protocols", + "type": "Planning", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 58, + "header": "Success Metrics", + "type": "Planning", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Eddie Lake" + }, + { + "id": 59, + "header": "Internationalization Support", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 60, + "header": "Backup and Recovery Procedures", + "type": "Technical content", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 61, + "header": "Monitoring and Alerting System", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Daniel Park" + }, + { + "id": 62, + "header": "Code Review Guidelines", + "type": "Technical content", + "status": "Done", + "target": "12", + "limit": "15", + "reviewer": "Eddie Lake" + }, + { + "id": 63, + "header": "Documentation Standards", + "type": "Technical content", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 64, + "header": "Release Management Process", + "type": "Planning", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Assign reviewer" + }, + { + "id": 65, + "header": "Feature Prioritization Matrix", + "type": "Planning", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Emma Davis" + }, + { + "id": 66, + "header": "Technical Debt Assessment", + "type": "Technical content", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Eddie Lake" + }, + { + "id": 67, + "header": "Capacity Planning", + "type": "Planning", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 68, + "header": "Service Level Agreements", + "type": "Legal", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Assign reviewer" + } +] \ No newline at end of file diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..bb2c9f7 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,26 @@ +"use client" +import { AppSidebar } from "./sidebar" +import { SiteHeader } from "./site-header" +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar" + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ) +} \ No newline at end of file diff --git a/app/admin/nav-documents.tsx b/app/admin/nav-documents.tsx new file mode 100644 index 0000000..03d5dc6 --- /dev/null +++ b/app/admin/nav-documents.tsx @@ -0,0 +1,92 @@ +"use client" + +import { + IconDots, + IconFolder, + IconShare3, + IconTrash, + type Icon, +} from "@tabler/icons-react" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +export function NavDocuments({ + items, +}: { + items: { + name: string + url: string + icon: Icon + }[] +}) { + const { isMobile } = useSidebar() + + return ( + + Documents + + {items.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + Open + + + + Share + + + + + Delete + + + + + ))} + + + + More + + + + + ) +} diff --git a/app/admin/nav-main.tsx b/app/admin/nav-main.tsx new file mode 100644 index 0000000..acc88a9 --- /dev/null +++ b/app/admin/nav-main.tsx @@ -0,0 +1,67 @@ +"use client" + +import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react" + +import { Button } from "@/components/ui/button" +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { useEffect, useState } from "react" + +import { usePathname } from "next/navigation" +import Link from "next/link" + +export function NavMain({ + items, +}: { + items: { + title: string + url: string + icon?: Icon + }[] +}) { + + const pathname = usePathname() + return ( + + + + + + + Quick Create + + + + + + {items.map((item) => ( + + + + + {item.icon && } + {item.title} + + + + ))} + + + + ) +} diff --git a/app/admin/nav-secondary.tsx b/app/admin/nav-secondary.tsx new file mode 100644 index 0000000..7ee5341 --- /dev/null +++ b/app/admin/nav-secondary.tsx @@ -0,0 +1,42 @@ +"use client" + +import * as React from "react" +import { type Icon } from "@tabler/icons-react" + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string + url: string + icon: Icon + }[] +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ) +} diff --git a/app/admin/nav-user.tsx b/app/admin/nav-user.tsx new file mode 100644 index 0000000..cf8ee7a --- /dev/null +++ b/app/admin/nav-user.tsx @@ -0,0 +1,110 @@ +"use client" + +import { + IconCreditCard, + IconDotsVertical, + IconLogout, + IconNotification, + IconUserCircle, +} from "@tabler/icons-react" + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +export function NavUser({ + user, +}: { + user: { + name: string + email: string + avatar: string + } +}) { + const { isMobile } = useSidebar() + + return ( + + + + + + + + CN + +
+ {user.name} + + {user.email} + +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + + {user.email} + +
+
+
+ + + + + Account + + + + Billing + + + + Notifications + + + + + + Log out + +
+
+
+
+ ) +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..92c74eb --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,6 @@ +// app/dashboard/page.tsx +import { redirect } from "next/navigation" + +export default function Dashboard() { + redirect("/admin/dashboard") +} diff --git a/app/admin/section-cards.tsx b/app/admin/section-cards.tsx new file mode 100644 index 0000000..25da063 --- /dev/null +++ b/app/admin/section-cards.tsx @@ -0,0 +1,102 @@ +import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react" + +import { Badge } from "@/components/ui/badge" +import { + Card, + CardAction, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +export function SectionCards() { + return ( +
+ + + Total Revenue + + $1,250.00 + + + + + +12.5% + + + + +
+ Trending up this month +
+
+ Visitors for the last 6 months +
+
+
+ + + New Customers + + 1,234 + + + + + -20% + + + + +
+ Down 20% this period +
+
+ Acquisition needs attention +
+
+
+ + + Active Accounts + + 45,678 + + + + + +12.5% + + + + +
+ Strong user retention +
+
Engagement exceed targets
+
+
+ + + Growth Rate + + 4.5% + + + + + +4.5% + + + + +
+ Steady performance increase +
+
Meets growth projections
+
+
+
+ ) +} diff --git a/app/admin/sidebar.tsx b/app/admin/sidebar.tsx new file mode 100644 index 0000000..dbfc627 --- /dev/null +++ b/app/admin/sidebar.tsx @@ -0,0 +1,169 @@ +"use client" + +import * as React from "react" +import { + IconCamera, + IconChartBar, + IconDashboard, + IconDatabase, + IconFileAi, + IconFileDescription, + IconFileWord, + IconFolder, + IconHelp, + IconInnerShadowTop, + IconListDetails, + IconReport, + IconSearch, + IconSettings, + IconUsers, +} from "@tabler/icons-react" + +import { NavDocuments } from "./nav-documents" +import { NavMain } from "./nav-main" +import { NavSecondary } from "./nav-secondary" +import { NavUser } from "./nav-user" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +const data = { + user: { + name: "shadcn", + email: "m@example.com", + avatar: "/avatars/shadcn.jpg", + }, + navMain: [ + { + title: "Dashboard", + url: "/admin/dashboard", + icon: IconDashboard, + }, + { + title: "Analytics", + url: "/admin/analytics", + icon: IconChartBar, + }, + { + title: "Users", + url: "/admin/users", + icon: IconUsers, + } + ], + navClouds: [ + { + title: "Capture", + icon: IconCamera, + isActive: true, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Proposal", + icon: IconFileDescription, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Prompts", + icon: IconFileAi, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + ], + navSecondary: [ + { + title: "Settings", + url: "#", + icon: IconSettings, + }, + { + title: "Get Help", + url: "#", + icon: IconHelp, + }, + { + title: "Search", + url: "#", + icon: IconSearch, + }, + ], + documents: [ + { + name: "Data Library", + url: "#", + icon: IconDatabase, + }, + { + name: "Reports", + url: "#", + icon: IconReport, + }, + + ], +} + +export function AppSidebar({ ...props }: React.ComponentProps) { + const [nav, setNav] = React.useState(data.navMain) + + return ( + + + + + + + + Acme Inc. + + + + + + + + + + + + + + + ) +} diff --git a/app/admin/site-header.tsx b/app/admin/site-header.tsx new file mode 100644 index 0000000..f4911ef --- /dev/null +++ b/app/admin/site-header.tsx @@ -0,0 +1,30 @@ +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { SidebarTrigger } from "@/components/ui/sidebar" + +export function SiteHeader() { + return ( +
+
+ + +

Documents

+
+ +
+
+
+ ) +} diff --git a/app/admin/users/create-user-form.tsx b/app/admin/users/create-user-form.tsx new file mode 100644 index 0000000..5c4b5e5 --- /dev/null +++ b/app/admin/users/create-user-form.tsx @@ -0,0 +1,262 @@ +"use client" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +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 { useRouter } from "next/navigation" +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 { IconCircleCheckFilled, IconLoader } from "@tabler/icons-react" + +const CREATE_USER = gql` + mutation CreateUser($username: String!, $email: String!, $role: String!, $password: String!) { + createUser(input: { + username: $username + email: $email + role: $role + password: $password + }) { + id + } + } +` + + +const schema = z.object({ + username: z.string().min(1, "用户名不能为空"), + password: z.string().min(1, "密码不能为空"), + role: z.enum(["ADMIN", "USER"]), + email: z.email() +}) + +export default function CreateUserForm({ + className, + ...props +}: React.ComponentProps<"form">) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isGenerating, setIsGenerating] = useState(false) + + const [createUser, { loading }] = useMutation(CREATE_USER) + + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { + username: "", + email: "", + password: "", + role: "USER" as const, + }, + }) + + async function onSubmit(values: z.infer) { + try { + setIsLoading(true); + setError(null); + + await createUser({ + variables: { + username: values.username, + password: values.password, + role: values.role, + email: values.email + } + }); + + // 重置表单 + form.reset(); + // 重新生成密码 + generateRandomPassword(); + + } catch (err) { + setError(err instanceof Error ? err.message : '创建用户失败,请重试'); + } finally { + setIsLoading(false); + } + } + + + // 生成随机密码的函数 + const generateRandomPassword = useCallback(() => { + setIsGenerating(true) + + // 模拟生成过程 + setTimeout(() => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*' + let password = '' + + // 确保包含至少一个大写字母、一个小写字母、一个数字和一个特殊字符 + password += chars[Math.floor(Math.random() * 26)] // 大写字母 + password += chars[26 + Math.floor(Math.random() * 26)] // 小写字母 + password += chars[52 + Math.floor(Math.random() * 10)] // 数字 + password += chars[62 + Math.floor(Math.random() * 8)] // 特殊字符 + + // 添加剩余的随机字符,总长度为12 + for (let i = 4; i < 12; i++) { + password += chars[Math.floor(Math.random() * chars.length)] + } + + // 打乱密码字符顺序 + password = password.split('').sort(() => Math.random() - 0.5).join('') + form.setValue('password', password) + setIsGenerating(false) + // toast.success('随机密码已生成') + }, 500) + }, [form]) + + useEffect(() => { + generateRandomPassword() + }, [generateRandomPassword]) + + + return ( +
+ + {error && ( +
+ {error} +
+ )} +
+
+ ( + + 用户名 * + + + + + + + )} /> +
+ +
+ ( + + 邮箱 * + + + + + + )} /> +
+ +
+ ( + + 角色 * + + + + + + )} + /> + +
+ +
+ + ( + + +
+ 密码 * + +
+ + +
+ + + +
+
+ +
+ )} + /> +
+
+ + + + + + + +
+ + + ) +} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx new file mode 100644 index 0000000..daad397 --- /dev/null +++ b/app/admin/users/page.tsx @@ -0,0 +1,17 @@ +"use client" + +import { UserTable } from "./user-table" + +export default function Page() { + return ( +
+
+
+

Users

+ +
+
+
+ + ) +} \ No newline at end of file diff --git a/app/admin/users/user-table.tsx b/app/admin/users/user-table.tsx new file mode 100644 index 0000000..310a311 --- /dev/null +++ b/app/admin/users/user-table.tsx @@ -0,0 +1,832 @@ +"use client" + +import * as React from "react" +import { useQuery, gql, useMutation } from '@apollo/client'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type UniqueIdentifier, +} 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, + IconCircleCheckFilled, + IconDotsVertical, + IconGripVertical, + IconLayoutColumns, + IconLoader, + 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 { Area, AreaChart, CartesianGrid, XAxis } from "recharts" +import { toast } from "sonner" +import { z } from "zod" + +import { useIsMobile } from "@/hooks/use-mobile" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +import { Checkbox } from "@/components/ui/checkbox" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + 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 { Separator } from "@/components/ui/separator" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" +import CreateUserForm from "./create-user-form"; + +export const schema = z.object({ + id: z.number(), + username: z.string(), + email: z.string(), + role: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +// Create a separate component for the drag handle +function DragHandle({ id }: { id: number }) { + 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: "name", + header: "Name", + cell: ({ row }) => { + return + }, + enableHiding: false, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => ( +
+ + {row.original.email} + +
+ ), + }, + { + accessorKey: "role", + header: "Role", + cell: ({ row }) => ( +
+ + {row.original.role} + +
+ ), + }, + { + accessorKey: "createdAt", + header: "Created At", + 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 = 'Today'; + } else if (diffInDays === 1) { + timeAgo = 'Yesterday'; + } else if (diffInDays < 7) { + timeAgo = `${diffInDays} days ago`; + } else if (diffInDays < 30) { + const weeks = Math.floor(diffInDays / 7); + timeAgo = `${weeks} week${weeks > 1 ? 's' : ''} ago`; + } else if (diffInDays < 365) { + const months = Math.floor(diffInDays / 30); + timeAgo = `${months} month${months > 1 ? 's' : ''} ago`; + } else { + const years = Math.floor(diffInDays / 365); + timeAgo = `${years} year${years > 1 ? 's' : ''} ago`; + } + + return ( +
+
+ + {date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + + {timeAgo} + +
+
+ ); + }, + }, + { + accessorKey: "lastLogin", + header: "Last Login", + 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 = 'Just now'; + } else if (diffInMinutes < 60) { + timeAgo = `${diffInMinutes} min${diffInMinutes > 1 ? 's' : ''} ago`; + } else if (diffInMinutes < 1440) { // 24 hours + const hours = Math.floor(diffInMinutes / 60); + timeAgo = `${hours} hour${hours > 1 ? 's' : ''} ago`; + } else { + const days = Math.floor(diffInMinutes / 1440); + timeAgo = `${days} day${days > 1 ? 's' : ''} ago`; + } + + return ( +
+
+ + {date.toLocaleDateString('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + + + {timeAgo} + +
+
+ ); + }, + }, + { + id: "actions", + cell: () => ( + + + + + + Edit + Make a copy + Favorite + + Delete + + + ), + }, +] + +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_USERS = gql` + query GetUsers($offset: Int, $limit: Int) { + users(offset: $offset, limit: $limit) { + id + username + email + role + createdAt + updatedAt + } + } +` + +const USERS_INFO = gql` + query UsersInfo { + usersInfo { + totalUsers + totalActiveUsers + totalAdminUsers + totalUserUsers + } + } +` + +export function UserTable() { + + const { data, loading, error, refetch } = useQuery(GET_USERS, { + variables: { + offset: 0, + limit: 10, + }, + }) + + const { data: usersInfo, loading: usersInfoLoading, error: usersInfoError } = useQuery(USERS_INFO) + + return ( + <> + {loading &&
Loading...
} + {error &&
Error: {error.message}
} + {data && } + + ) + + +} + +function Tabel({ data, refetch, info }: { data: any, refetch: any, info: any }) { + + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = + React.useState({}) + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + const [sorting, setSorting] = React.useState([]) + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + const sortableId = React.useId() + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ) + + React.useEffect(() => { + refetch({ + offset: pagination.pageIndex * pagination.pageSize, + limit: pagination.pageSize, + }) + }, [pagination, refetch]) + + const table = useReactTable({ + data: data?.users || [], + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination, + }, + 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, + }) + + // function handleDragEnd(event: DragEndEvent) { + // const { active, over } = event + // if (active && over && active.id !== over.id) { + // setData((data) => { + // const oldIndex = dataIds.indexOf(active.id) + // const newIndex = dataIds.indexOf(over.id) + // return arrayMove(data, oldIndex, newIndex) + // }) + // } + // } + + return ( + + + + 添加新用户 + + 创建新用户账户。系统将自动生成随机密码。 + + + + + + +
+ + + + All Users + + Active Users {info?.usersInfo?.totalActiveUsers} + + + Administrators {info?.usersInfo?.totalAdminUsers} + + Inactive Users + +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + + + + + +
+
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + user.id) || []} + strategy={verticalListSortingStrategy} + > + {table.getRowModel().rows.map((row) => ( + + ))} + + ) : ( + + + No results. + + + )} + +
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ ) + +} + +const chartData = [ + { month: "January", desktop: 186, mobile: 80 }, + { month: "February", desktop: 305, mobile: 200 }, + { month: "March", desktop: 237, mobile: 120 }, + { month: "April", desktop: 73, mobile: 190 }, + { month: "May", desktop: 209, mobile: 130 }, + { month: "June", desktop: 214, mobile: 140 }, +] + +const chartConfig = { + desktop: { + label: "Desktop", + color: "var(--primary)", + }, + mobile: { + label: "Mobile", + color: "var(--primary)", + }, +} satisfies ChartConfig + +function TableCellViewer({ item }: { item: z.infer }) { + const isMobile = useIsMobile() + + return ( + + + + + + + {item.username} + + User profile and activity information + + +
+ {!isMobile && ( + <> + + + + value.slice(0, 3)} + hide + /> + } + /> + + + + + +
+
+ Login activity up by 5.2% this month{" "} + +
+
+ User activity and login statistics for the last 6 months. This shows + the user's engagement patterns and system usage over time. +
+
+ + + )} +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + + + + + +
+
+ ) +} diff --git a/app/app-sidebar.tsx b/app/app-sidebar.tsx index 23ff3ba..38b6654 100644 --- a/app/app-sidebar.tsx +++ b/app/app-sidebar.tsx @@ -20,6 +20,7 @@ import { Slider } from "@/components/ui/slider" import { useMapLocation, type LocationKey } from "@/hooks/use-map-location" import { ThemeToggle } from "@/components/theme-toggle" import { useMapZoom } from "@/hooks/use-map-zoom" +import { useUser } from "./user-context" // This is sample data. const data = { @@ -48,11 +49,14 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const { currentLocation, flyToLocation, isMapReady } = useMapLocation(); const { zoomToLocation, zoomIn, zoomOut, mapState } = useMapZoom(); + const { user } = useUser() return ( - + { + user ? : null + } {/* */} diff --git a/app/client-provider.tsx b/app/client-provider.tsx new file mode 100644 index 0000000..2d3facd --- /dev/null +++ b/app/client-provider.tsx @@ -0,0 +1,27 @@ +'use client' + +import { ReactNode, useEffect } from 'react' +import { setGlobalApolloClient, UserProvider } from './user-context' +import { ThemeProvider } from '@/components/theme-provider' +import { ApolloProvider } from '@apollo/client' +import { createApolloClient } from '@/lib/apollo-client' +import { MapProvider } from './map-context' + +export function ClientProviders({ children }: { children: ReactNode }) { + const client = createApolloClient(); + useEffect(() => { + setGlobalApolloClient(client); + }, [client]); + + return ( + + + + + {children} + + + + + ) +} diff --git a/app/glsl/layers/triangle_frag.glsl b/app/glsl/layers/triangle_frag.glsl new file mode 100644 index 0000000..ec3fb35 --- /dev/null +++ b/app/glsl/layers/triangle_frag.glsl @@ -0,0 +1,6 @@ +#version 300 es +precision highp float; +out vec4 color; +void main(){ + color=vec4(1.,0.,0.,.5); +} \ No newline at end of file diff --git a/app/glsl/layers/triangle_vert.glsl b/app/glsl/layers/triangle_vert.glsl new file mode 100644 index 0000000..bf350ca --- /dev/null +++ b/app/glsl/layers/triangle_vert.glsl @@ -0,0 +1,8 @@ +#version 300 es + +in vec2 a_pos; +uniform mat4 u_matrix; + +void main(){ + gl_Position=u_matrix*vec4(a_pos,0,1); +}; \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 26df4f7..739c029 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import { TwentyFirstToolbar } from '@21st-extension/toolbar-next'; -import { ReactPlugin } from '@21st-extension/react'; -import { ThemeProvider } from '@/components/theme-provider'; -import { MapProvider } from "./map-context"; +import { ClientProviders } from "./client-provider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -26,21 +23,15 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + return ( - - - {children} - - - + + {children} + ); diff --git a/app/login/login-form.tsx b/app/login/login-form.tsx new file mode 100644 index 0000000..dc39b4c --- /dev/null +++ b/app/login/login-form.tsx @@ -0,0 +1,132 @@ +"use client" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +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 { useUser } from "../user-context" +import { useRouter } from "next/navigation" +import { useState } from "react" + +const schema = z.object({ + username: z.string().min(1, "用户名不能为空"), + password: z.string().min(1, "密码不能为空"), +}) + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"form">) { + const { login } = useUser(); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { + username: "", + password: "", + }, + }) + + async function onSubmit(values: z.infer) { + try { + setIsLoading(true); + setError(null); + debugger + await login(values); + // clearMap(); + router.push('/'); + } catch (err) { + setError(err instanceof Error ? err.message : '登录失败,请重试'); + } finally { + setIsLoading(false); + } + } + + return ( +
+ +
+

登录您的账户

+

+ 请输入您的用户名和密码登录 +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ ( + + 用户名 + + + + + + )} /> +
+
+ + ( + + + + + + + + + )} /> + +
+ +
+ + 或者使用 + +
+ +
+
+ 还没有账户?{" "} + + 注册 + +
+
+ + + ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..10cab8d --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,76 @@ +"use client" + +import { GalleryVerticalEnd } from "lucide-react" +import { LoginForm } from "./login-form" +import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client"; +import { UserProvider, useUser } from "../user-context"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function LoginPage() { + const { isAuthenticated, isLoading } = useUser(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && isAuthenticated) { + router.push('/'); + } + }, [isAuthenticated, isLoading, router]); + + // 如果正在加载或已认证,显示加载状态 + if (isLoading || isAuthenticated) { + return ( +
+
+ +
+
+

正在跳转...

+
+
+
+
+ Image +
+
+ ); + } + + return ( +
+
+ +
+
+ +
+
+
+
+ Image +
+
+ ); +} \ No newline at end of file diff --git a/app/map-context.tsx b/app/map-context.tsx index dc06d43..374bf0e 100644 --- a/app/map-context.tsx +++ b/app/map-context.tsx @@ -16,6 +16,7 @@ interface MapContextType { zoomOut: () => void zoomTo: (zoom: number) => void reset: () => void + clearMap: () => void setTime: (date: Date) => void isMapReady: boolean } @@ -44,6 +45,12 @@ export function MapProvider({ children }: MapProviderProps) { const setMap = (map: Map, layers: any[]) => { + // 如果已经有地图实例,先清理旧的 + if (mapRef.current) { + console.log('Cleaning up previous map instance...'); + mapRef.current = null; + } + // 监听视图变化事件 // const view = map.getView(); @@ -132,6 +139,19 @@ export function MapProvider({ children }: MapProviderProps) { } } + const clearMap = () => { + if (mapRef.current) { + console.log('Clearing map instance...'); + mapRef.current.remove(); + mapRef.current = null; + layersRef.current = []; + setIsMapReady(false); + setMapState({ + zoomLevel: 11 + }); + } + } + const value: MapContextType = { setTime, mapRef, @@ -143,6 +163,7 @@ export function MapProvider({ children }: MapProviderProps) { zoomOut, zoomTo, reset, + clearMap, isMapReady } diff --git a/app/me/layout.tsx b/app/me/layout.tsx new file mode 100644 index 0000000..c1f589d --- /dev/null +++ b/app/me/layout.tsx @@ -0,0 +1,25 @@ +"use client" +import { AppSidebar } from "./sidebar" +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar" + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ) +} \ No newline at end of file diff --git a/app/me/nav-main.tsx b/app/me/nav-main.tsx new file mode 100644 index 0000000..acc88a9 --- /dev/null +++ b/app/me/nav-main.tsx @@ -0,0 +1,67 @@ +"use client" + +import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react" + +import { Button } from "@/components/ui/button" +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { useEffect, useState } from "react" + +import { usePathname } from "next/navigation" +import Link from "next/link" + +export function NavMain({ + items, +}: { + items: { + title: string + url: string + icon?: Icon + }[] +}) { + + const pathname = usePathname() + return ( + + + + + + + Quick Create + + + + + + {items.map((item) => ( + + + + + {item.icon && } + {item.title} + + + + ))} + + + + ) +} diff --git a/app/me/page.tsx b/app/me/page.tsx new file mode 100644 index 0000000..83ffc1f --- /dev/null +++ b/app/me/page.tsx @@ -0,0 +1,21 @@ +"use client" + +import { redirect } from "next/navigation" +import { useUser } from "../user-context" +import { useEffect } from "react" + +export default function MePage() { + const { isAuthenticated, user } = useUser() + + useEffect(() => { + if (!isAuthenticated) { + redirect("/login") + } + }, [isAuthenticated]) + + return ( +
+

Me

+
+ ) +} \ No newline at end of file diff --git a/app/me/sidebar.tsx b/app/me/sidebar.tsx new file mode 100644 index 0000000..b045b3a --- /dev/null +++ b/app/me/sidebar.tsx @@ -0,0 +1,164 @@ +"use client" + +import * as React from "react" +import { + IconCamera, + IconChartBar, + IconDashboard, + IconDatabase, + IconFileAi, + IconFileDescription, + IconFileWord, + IconFolder, + IconHelp, + IconInnerShadowTop, + IconListDetails, + IconReport, + IconSearch, + IconSettings, + IconUsers, +} from "@tabler/icons-react" + +import { NavMain } from "./nav-main" + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +const data = { + user: { + name: "shadcn", + email: "m@example.com", + avatar: "/avatars/shadcn.jpg", + }, + navMain: [ + { + title: "Dashboard", + url: "/admin/dashboard", + icon: IconDashboard, + }, + { + title: "Analytics", + url: "/admin/analytics", + icon: IconChartBar, + }, + { + title: "Users", + url: "/admin/users", + icon: IconUsers, + } + ], + navClouds: [ + { + title: "Capture", + icon: IconCamera, + isActive: true, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Proposal", + icon: IconFileDescription, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Prompts", + icon: IconFileAi, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + ], + navSecondary: [ + { + title: "Settings", + url: "#", + icon: IconSettings, + }, + { + title: "Get Help", + url: "#", + icon: IconHelp, + }, + { + title: "Search", + url: "#", + icon: IconSearch, + }, + ], + documents: [ + { + name: "Data Library", + url: "#", + icon: IconDatabase, + }, + { + name: "Reports", + url: "#", + icon: IconReport, + }, + + ], +} + +export function AppSidebar({ ...props }: React.ComponentProps) { + const [nav, setNav] = React.useState(data.navMain) + + return ( + + + + + + + + Acme Inc. + + + + + + + + + + + + ) +} diff --git a/app/nav-user.tsx b/app/nav-user.tsx index 9c5dea8..91841ee 100644 --- a/app/nav-user.tsx +++ b/app/nav-user.tsx @@ -29,17 +29,12 @@ import { SidebarMenuItem, useSidebar, } from '@/components/ui/sidebar' +import { User } from "@/types/user" +import { useUser } from "./user-context" -export function NavUser({ - user, -}: { - user: { - name: string - email: string - avatar: string - } -}) { +export function NavUser({ user }: { user: User }) { const { isMobile } = useSidebar() + const { logout } = useUser() return ( @@ -102,7 +97,7 @@ export function NavUser({ - + Log out diff --git a/app/page.tsx b/app/page.tsx index e835dea..10e60de 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ 'use client' + import { AppSidebar } from '@/app/app-sidebar' import { Breadcrumb, @@ -15,40 +16,19 @@ import { import { MapComponent } from '@/components/map-component'; import { ThemeToggle } from '@/components/theme-toggle'; import { Timeline } from '@/app/timeline'; - -import { - Home, - Search, - Settings, - User, - Play, -} from "lucide-react" import { cn } from '@/lib/utils'; import { useTimeline } from '@/hooks/use-timeline'; import { useEffect } from 'react' import { useRadarTile } from '@/hooks/use-radartile' - export default function Page() { - const items = [ - { icon: Home, label: "Home" }, - { icon: Search, label: "Search" }, - { icon: Play, label: "Play" }, - { icon: User, label: "Profile" }, - { icon: Settings, label: "Settings" } - ] - // 创建默认时间范围(过去7天到未来3天) const now = new Date(); const startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7天前 const endDate = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000); // 3天后 const { setTime } = useTimeline() - const { fetchRadarTile } = useRadarTile({}) - - useEffect(() => { - fetchRadarTile("http://127.0.0.1:3050/test") - }, []) + const { imgBitmap, fetchRadarTile } = useRadarTile({}) return ( @@ -69,7 +49,7 @@ export default function Page() {
- + = ({ gl.viewport(0, 0, actualWidth, actualHeight); render(); + + // 清理函数:当组件卸载或重新初始化时清理WebGL资源 + return () => { + console.log('Cleaning up timeline WebGL resources...'); + + if (gl) { + // 清理WebGL资源 + if (programRef.current) { + gl.deleteProgram(programRef.current); + programRef.current = null; + } + if (vaoRef.current) { + gl.deleteVertexArray(vaoRef.current); + vaoRef.current = null; + } + if (vertex_bfRef.current) { + gl.deleteBuffer(vertex_bfRef.current); + vertex_bfRef.current = null; + } + if (uniform_bfRef.current) { + gl.deleteBuffer(uniform_bfRef.current); + uniform_bfRef.current = null; + } + if (instants_bfRef.current) { + gl.deleteBuffer(instants_bfRef.current); + instants_bfRef.current = null; + } + } + + // 重置实例计数 + instants_countRef.current = 0; + } }, [canvasRef.current]); return ( diff --git a/app/user-context.tsx b/app/user-context.tsx new file mode 100644 index 0000000..24e7258 --- /dev/null +++ b/app/user-context.tsx @@ -0,0 +1,416 @@ +'use client' + +import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react' +import { AuthState, User, LoginCredentials, RegisterData, JWTPayload } from '@/types/user' +import { useMutation, gql, useQuery, ApolloClient } from '@apollo/client' + +interface UserContextType extends AuthState { + login: (credentials: LoginCredentials) => Promise + register: (data: RegisterData) => Promise + logout: () => void + refreshToken: () => Promise + resetApolloCache: () => void + refetchUser: () => Promise +} + +const UserContext = createContext(undefined) + +interface UserProviderProps { + children: ReactNode +} + +const TOKEN_KEY = 'auth_token' +const GET_USER_QUERY = gql` + query GetUser { + currentUser { + id + username + email + } + } +` + +const LOGIN_MUTATION = gql` + mutation Login($username: String!, $password: String!) { + login(input: { username: $username, password: $password }) { + token + } + } +` + +const REGISTER_MUTATION = gql` + mutation Register($username: String!, $password: String!) { + register(username: $username, password: $password) { + token + } + } +` + +// 全局 Apollo Client 实例引用 +let globalApolloClient: ApolloClient | null = null; + +export function setGlobalApolloClient(client: ApolloClient) { + globalApolloClient = client; +} + +export function UserProvider({ children }: UserProviderProps) { + const [authState, setAuthState] = useState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: true + }) + + // 将 useMutation hooks 移到组件顶层 + const [loginMutation, { loading: loginLoading, error: loginError }] = useMutation(LOGIN_MUTATION) + const [registerMutation, { loading: registerLoading, error: registerError }] = useMutation(REGISTER_MUTATION) + + // 定期查询用户信息的 hook + const { data: userData, loading: userLoading, error: userError, refetch: refetchUser } = useQuery(GET_USER_QUERY, { + skip: !authState.isAuthenticated, + pollInterval: 30000, // 每30秒查询一次 + errorPolicy: 'all', + notifyOnNetworkStatusChange: true + }) + + const parseJWT = (token: string): JWTPayload | null => { + try { + const base64Url = token.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ) + return JSON.parse(jsonPayload) + } catch (error) { + console.error('Failed to parse JWT:', error) + return null + } + } + + const isTokenValid = (token: string): boolean => { + const payload = parseJWT(token) + if (!payload) return false + + const currentTime = Date.now() / 1000 + return payload.exp > currentTime + } + + const resetApolloCache = () => { + if (globalApolloClient) { + globalApolloClient.resetStore(); + } + } + + const initializeAuth = () => { + try { + const token = localStorage.getItem(TOKEN_KEY) + + debugger + + if (token && isTokenValid(token)) { + const payload = parseJWT(token) + if (payload) { + const user: User = { + id: payload.sub, + email: payload.email, + name: payload.name, + role: payload.role + } + + setAuthState({ + user, + token, + isAuthenticated: true, + isLoading: false + }) + return + } + } + + localStorage.removeItem(TOKEN_KEY) + setAuthState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false + }) + } catch (error) { + console.error('Auth initialization error:', error) + setAuthState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false + }) + } + } + + const login = async (credentials: LoginCredentials) => { + try { + setAuthState(prev => ({ ...prev, isLoading: true })) + + debugger + + const response = await loginMutation({ variables: credentials }) + + if (loginError) { + throw new Error('Login failed') + } + + const { token } = response.data.login + + if (!isTokenValid(token)) { + throw new Error('Invalid token received') + } + + debugger + + const payload = parseJWT(token) + if (!payload) { + throw new Error('Failed to parse token') + } + + const user: User = { + id: payload.sub, + email: payload.email, + name: payload.name, + role: payload.role + } + + localStorage.setItem(TOKEN_KEY, token) + setAuthState({ + user, + token, + isAuthenticated: true, + isLoading: false + }) + + // 登录成功后重置 Apollo 缓存 + resetApolloCache() + } catch (error) { + console.error('Login error:', error) + setAuthState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false + }) + throw error + } + } + + const register = async (data: RegisterData) => { + try { + setAuthState(prev => ({ ...prev, isLoading: true })) + + const response = await registerMutation({ variables: data }) + + if (registerError) { + throw new Error('Registration failed') + } + + const { token } = response.data.register + + if (!isTokenValid(token)) { + throw new Error('Invalid token received') + } + + const payload = parseJWT(token) + if (!payload) { + throw new Error('Failed to parse token') + } + + const user: User = { + id: payload.sub, + email: payload.email, + name: payload.name, + role: payload.role + } + + localStorage.setItem(TOKEN_KEY, token) + setAuthState({ + user, + token, + isAuthenticated: true, + isLoading: false + }) + + // 注册成功后重置 Apollo 缓存 + resetApolloCache() + } catch (error) { + console.error('Registration error:', error) + setAuthState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false + }) + throw error + } + } + + const logout = () => { + localStorage.removeItem(TOKEN_KEY) + setAuthState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false + }) + + // 登出后重置 Apollo 缓存 + resetApolloCache() + } + + const refreshToken = async () => { + try { + const currentToken = authState.token + if (!currentToken) { + throw new Error('No token to refresh') + } + + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${currentToken}`, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + throw new Error('Token refresh failed') + } + + const { token } = await response.json() + + if (!isTokenValid(token)) { + throw new Error('Invalid refreshed token') + } + + const payload = parseJWT(token) + if (!payload) { + throw new Error('Failed to parse refreshed token') + } + + const user: User = { + id: payload.sub, + email: payload.email, + name: payload.name, + role: payload.role + } + + localStorage.setItem(TOKEN_KEY, token) + setAuthState({ + user, + token, + isAuthenticated: true, + isLoading: false + }) + + // Token 刷新后重置 Apollo 缓存 + resetApolloCache() + } catch (error) { + console.error('Token refresh error:', error) + logout() + throw error + } + } + + // 更新用户信息的函数 + const updateUserInfo = (userData: any) => { + if (userData?.currentUser) { + const updatedUser: User = { + id: userData.currentUser.id, + email: userData.currentUser.email, + name: userData.currentUser.name, + avatar: userData.currentUser.avatar, + role: userData.currentUser.role + } + + setAuthState(prev => ({ + ...prev, + user: updatedUser + })) + } + } + + useEffect(() => { + initializeAuth() + }, []) + + // 监听登录和注册的 loading 状态 + useEffect(() => { + setAuthState(prev => ({ ...prev, isLoading: loginLoading || registerLoading || userLoading })) + }, [loginLoading, registerLoading, userLoading]) + + // 监听用户数据变化,定期更新用户信息 + useEffect(() => { + if (userData && authState.isAuthenticated) { + updateUserInfo(userData) + } + }, [userData, authState.isAuthenticated]) + + // 处理用户查询错误 + useEffect(() => { + if (userError && authState.isAuthenticated) { + console.error('User data fetch error:', userError) + // 如果用户查询失败,可能是token过期,尝试刷新token + if (userError.message.includes('Unauthorized') || userError.message.includes('401')) { + refreshToken().catch(() => { + logout() + }) + } + } + }, [userError, authState.isAuthenticated]) + + useEffect(() => { + if (authState.token && authState.isAuthenticated) { + const payload = parseJWT(authState.token) + if (payload) { + const timeUntilExpiry = (payload.exp * 1000) - Date.now() + const refreshTime = Math.max(timeUntilExpiry - 5 * 60 * 1000, 30 * 1000) + + const refreshTimer = setTimeout(() => { + refreshToken().catch(() => { + logout() + }) + }, refreshTime) + + return () => clearTimeout(refreshTimer) + } + } + }, [authState.token, authState.isAuthenticated]) + + const value: UserContextType = { + ...authState, + login, + register, + logout, + refreshToken, + resetApolloCache, + refetchUser + } + + return ( + + {children} + + ) +} + +export function useUser() { + const context = useContext(UserContext) + + if (context === undefined) { + throw new Error('useUser must be used within a UserProvider') + } + + return context +} + +export function useAuth() { + return useUser() +} \ No newline at end of file diff --git a/components/colorbar.tsx b/components/colorbar.tsx new file mode 100644 index 0000000..a5448fc --- /dev/null +++ b/components/colorbar.tsx @@ -0,0 +1,133 @@ +'use client' + +import React, { useRef, useEffect, useState } from 'react' +import { createColorMap, ColorMapType } from '@/lib/color-maps' + +interface ColorbarProps { + colorMapType: ColorMapType + width?: number + height?: number + showLabels?: boolean + minValue?: number + maxValue?: number + unit?: string +} + +export function Colorbar({ + colorMapType, + width = 200, + height = 20, + showLabels = true, + minValue = 0, + maxValue = 100, + unit = '' +}: ColorbarProps) { + const canvasRef = useRef(null) + const [isHovered, setIsHovered] = useState(false) + const [hoverValue, setHoverValue] = useState(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + + // 设置canvas尺寸 + canvas.width = width + canvas.height = height + + // 获取色标数据 + const colorMap = createColorMap(colorMapType) + + // 绘制色标 + const imageData = ctx.createImageData(width, height) + + for (let x = 0; x < width; x++) { + const t = x / (width - 1) // 归一化到 0-1 + const colorIndex = Math.floor(t * 255) * 4 + + const r = colorMap[colorIndex] + const g = colorMap[colorIndex + 1] + const b = colorMap[colorIndex + 2] + const a = colorMap[colorIndex + 3] + + for (let y = 0; y < height; y++) { + const pixelIndex = (y * width + x) * 4 + imageData.data[pixelIndex] = r + imageData.data[pixelIndex + 1] = g + imageData.data[pixelIndex + 2] = b + imageData.data[pixelIndex + 3] = a + } + } + + ctx.putImageData(imageData, 0, 0) + + // 添加边框 + ctx.strokeStyle = '#666' + ctx.lineWidth = 1 + ctx.strokeRect(0, 0, width, height) + + }, [colorMapType, width, height]) + + const handleMouseMove = (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const t = x / width + const value = minValue + t * (maxValue - minValue) + setHoverValue(value) + setIsHovered(true) + } + + const handleMouseLeave = () => { + setIsHovered(false) + setHoverValue(null) + } + + const formatValue = (value: number) => { + if (unit === '%') { + return `${value.toFixed(1)}%` + } else if (unit === 'dBZ') { + return `${value.toFixed(1)} dBZ` + } else if (unit === 'mm/h') { + return `${value.toFixed(1)} mm/h` + } else { + return value.toFixed(1) + } + } + + return ( +
+ + + {showLabels && ( +
+ {formatValue(minValue)} + {formatValue(maxValue)} +
+ )} + + {isHovered && hoverValue !== null && ( +
+ {formatValue(hoverValue)} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/components/map-component.tsx b/components/map-component.tsx index 53f5cf5..b7d4291 100644 --- a/components/map-component.tsx +++ b/components/map-component.tsx @@ -1,64 +1,44 @@ 'use client' - -import React, { useEffect, useRef } from 'react' -import maplibregl, { CustomLayerInterface, CustomRenderMethodInput, createTileMesh, Projection } from 'maplibre-gl' +import React, { useEffect, useRef, useState } from 'react' +import maplibregl, { CustomLayerInterface, CustomRenderMethodInput, } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { useMap } from '@/app/map-context' import { useMapLocation } from '@/hooks/use-map-location' -import { createOptimalWorldMesh, getSubdivisionRecommendation, detectPerformanceLevel, createOptimalRegionMesh, RegionMeshPresets } from '@/lib/tile-mesh' -import { useRadarTile } from '@/hooks/use-radartile' +import { getSubdivisionRecommendation, detectPerformanceLevel, RegionMeshPresets } from '@/lib/tile-mesh' +import { createColorMap, ColorMapType, } from '@/lib/color-maps' +import { Colorbar } from './colorbar' interface MapComponentProps { style?: string center?: [number, number] zoom?: number + imgBitmap?: ImageBitmap | null + colorMapType?: ColorMapType + onColorMapChange?: (type: ColorMapType) => void } -// const extent = transformExtent([-126, 24, -66, 50], 'EPSG:4326', 'EPSG:3857'); - export function MapComponent({ style = 'https://api.maptiler.com/maps/019817f1-82a8-7f37-901d-4bedf68b27fb/style.json?key=hj3fxRdwF9KjEsBq8sYI', // center = [103.851959, 1.290270], // zoom = 11 + imgBitmap: propImgBitmap, + colorMapType = 'heatmap', + onColorMapChange }: MapComponentProps) { const mapContainer = useRef(null) const { setMap } = useMap() const { location } = useMapLocation() - const { radarTileRef } = useRadarTile() + const imgBitmap = propImgBitmap const texRef = useRef(null) const lutTexRef = useRef(null) const glRef = useRef(null) + const customLayerRef = useRef(null) + const [isReady, setIsReady] = useState(false) + const [currentColorMapType, setCurrentColorMapType] = useState(colorMapType) useEffect(() => { if (!mapContainer.current) return - // const tileWmsLayer = new TileLayer({ - // extent: extent, - // source: new TileWMS({ - // attributions: ['Iowa State University'], - // url: 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r-t.cgi', - // params: { 'LAYERS': 'nexrad-n0r-wmst' }, - // }), - // opacity: 0.7, - // }); - - // const map = new Map({ - // target: mapContainer.current, - // view: new View({ - // center: fromLonLat(location.center), - // zoom: location.zoom, - // projection: 'EPSG:3857', - - // showFullExtent: true, - // enableRotation: true - - // }), - // }) - - // apply(map, style).then(() => { - // map.addLayer(tileWmsLayer) - // }) - const map = new maplibregl.Map({ container: mapContainer.current, style: style, @@ -75,15 +55,6 @@ export function MapComponent({ type: 'globe' }) - // map.addSource('nexrad', { - // type: 'raster', - // tiles: [ - // // 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r-t.cgi?service=WMS&version=1.3.0&request=GetMap&layers=nexrad-n0r-wmst&styles=&format=image/png&transparent=true&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256' - // 'http://127.0.0.1:3050/tiles/{z}/{x}/{y}?time=202507220012' - // ], - // tileSize: 256 - // }); - const customGlLayer: CustomGlLayer = { id: 'player', type: 'custom', @@ -92,6 +63,7 @@ export function MapComponent({ prerender(gl: WebGLRenderingContext | WebGL2RenderingContext, { shaderData }: CustomRenderMethodInput) { + if (!this.program) { glRef.current = gl as WebGL2RenderingContext; if (!(gl instanceof WebGL2RenderingContext)) { @@ -123,9 +95,22 @@ export function MapComponent({ in vec2 v_tex_coord; void main() { - float value = texture(u_tex, v_tex_coord).r; + vec4 texColor = texture(u_tex, v_tex_coord); + // 对于灰度图,RGB通道通常相同,取红色通道作为灰度值 + float value = texColor.r * 3.4; + + if (value == 0.0) { + discard; + } + + // normalizedValue = clamp(normalizedValue, 0.0, 1.0); + + // 使用 LUT 进行颜色映射 vec4 lutColor = texture(u_lut, vec2(value, 0.5)); - fragColor = lutColor; + // 添加一些透明度,使低值区域更透明 + // float alpha = smoothstep(0.0, 0.1, value); + float alpha = 1.0; + fragColor = vec4(lutColor.rgb, alpha); }` console.log(vertexSource, fragmentSource) @@ -183,13 +168,15 @@ export function MapComponent({ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // 初始化时不更新纹理,等待 useEffect 中的更新 gl.bindTexture(gl.TEXTURE_2D, null); this.tex = tex; texRef.current = tex; // 创建 LUT 纹理 - const lutTex = createLutTexture(gl); + const lutTex = createLutTexture(gl, currentColorMapType); if (!lutTex) return; this.lutTex = lutTex; @@ -263,6 +250,8 @@ export function MapComponent({ this.vao = vao; this.vertexBuffer = vertexBuffer; this.indexBuffer = indexBuffer; + + setIsReady(true) } // 只在缩放级别变化时更新网格数据 @@ -301,10 +290,15 @@ export function MapComponent({ this.lastZoom = currentZoom; } } + + // 移除这里的纹理更新,避免循环更新 + + }, onAdd: function (map: maplibregl.Map, gl: WebGL2RenderingContext) { console.log('Custom layer added'); + customLayerRef.current = this; }, onRemove: function (map: maplibregl.Map, gl: WebGL2RenderingContext) { @@ -417,14 +411,35 @@ export function MapComponent({ setMap(map, []) + // 清理函数:当组件卸载或重新初始化时清理资源 + return () => { + console.log('Cleaning up map resources...'); + + // 清理自定义图层引用 + customLayerRef.current = null; + + // 清理 WebGL 引用 + glRef.current = null; + texRef.current = null; + lutTexRef.current = null; + + // 重置状态 + setIsReady(false); + + // 移除地图实例 + if (map) { + map.remove(); + } + } + }, [mapContainer]) useEffect(() => { - if (radarTileRef.current.imgBitmap && texRef.current) { + if (imgBitmap && texRef.current) { const gl = glRef.current if (!gl) return; - debugger + console.log('Updating texture with imgBitmap:', imgBitmap); gl.bindTexture(gl.TEXTURE_2D, texRef.current) @@ -433,29 +448,68 @@ export function MapComponent({ gl.texImage2D( gl.TEXTURE_2D, 0, - gl.RED, // 内部格式:单通道红色 + gl.RGBA, // 内部格式:使用RGBA,兼容性更好 gl.RGBA, // 数据格式:ImageBitmap总是RGBA gl.UNSIGNED_BYTE, - radarTileRef.current.imgBitmap + imgBitmap ) // 设置纹理参数(如果还没有设置) - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.bindTexture(gl.TEXTURE_2D, null); } - }, [radarTileRef.current.imgBitmap]) + }, [imgBitmap, isReady]) + + // 监听色标类型变化,更新LUT纹理 + useEffect(() => { + if (currentColorMapType !== colorMapType) { + setCurrentColorMapType(colorMapType); + } + }, [colorMapType, currentColorMapType]) + + // 当色标类型改变时,重新创建LUT纹理 + useEffect(() => { + if (isReady && lutTexRef.current && glRef.current) { + const gl = glRef.current; + const newLutTex = createLutTexture(gl, currentColorMapType); + if (newLutTex) { + // 删除旧的纹理 + gl.deleteTexture(lutTexRef.current); + lutTexRef.current = newLutTex; + + // 通知自定义图层更新LUT纹理 + if (customLayerRef.current) { + customLayerRef.current.lutTex = newLutTex; + } + } + } + }, [currentColorMapType, isReady]) return ( -
+
+
+ + {/* Colorbar 在右下角 */} +
+
色标
+ +
+
) } @@ -477,14 +531,9 @@ interface CustomGlLayer extends CustomLayerInterface { -function createLutTexture(gl: WebGL2RenderingContext) { - const lut = new Uint8Array(256 * 4); - for (let i = 0; i < 256; i++) { - lut[i * 4] = i; - lut[i * 4 + 1] = i; - lut[i * 4 + 2] = i; - lut[i * 4 + 3] = 255; - } +function createLutTexture(gl: WebGL2RenderingContext, colorMapType: ColorMapType = 'radar') { + // 使用统一的色标创建函数 + const lut = createColorMap(colorMapType); const tex = gl.createTexture() if (!tex) { diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 0000000..97cc280 --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,353 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] +}) { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +