me and admin

This commit is contained in:
Tsuki 2025-07-28 07:25:32 +08:00
parent 8e847d44a8
commit 1eca50c6ce
51 changed files with 7754 additions and 180 deletions

View File

@ -0,0 +1,14 @@
"use client"
export default function Page() {
return (
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
</div>
</div>
</div>
)
}

View File

@ -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 (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
defaultIndex={isMobile ? -1 : 10}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,14 @@
"use client"
export default function Page() {
return (
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
</div>
</div>
</div>
)
}

807
app/admin/data-table.tsx Normal file
View File

@ -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 (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
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<UniqueIdentifier[]>(
() => 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 (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
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<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="text-foreground w-fit px-0 text-left">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
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.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

614
app/admin/data.json Normal file
View File

@ -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"
}
]

26
app/admin/layout.tsx Normal file
View File

@ -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 (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
)
}

View File

@ -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 (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="data-[state=open]:bg-accent rounded-sm"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

67
app/admin/nav-main.tsx Normal file
View File

@ -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 (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
tooltip="Quick Create"
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
>
<IconCirclePlusFilled />
<span>Quick Create</span>
</SidebarMenuButton>
<Button
size="icon"
className="size-8 group-data-[collapsible=icon]:opacity-0"
variant="outline"
>
<IconMail />
<span className="sr-only">Inbox</span>
</Button>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<Link href={item.url}>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url} >
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup >
)
}

View File

@ -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<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

110
app/admin/nav-user.tsx Normal file
View File

@ -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 (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<IconUserCircle />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<IconCreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<IconNotification />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<IconLogout />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

6
app/admin/page.tsx Normal file
View File

@ -0,0 +1,6 @@
// app/dashboard/page.tsx
import { redirect } from "next/navigation"
export default function Dashboard() {
redirect("/admin/dashboard")
}

102
app/admin/section-cards.tsx Normal file
View File

@ -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 (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
$1,250.00
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Trending up this month <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
1,234
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Down 20% this period <IconTrendingDown className="size-4" />
</div>
<div className="text-muted-foreground">
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
45,678
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Strong user retention <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
4.5%
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Steady performance increase <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Meets growth projections</div>
</CardFooter>
</Card>
</div>
)
}

169
app/admin/sidebar.tsx Normal file
View File

@ -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<typeof Sidebar>) {
const [nav, setNav] = React.useState(data.navMain)
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<a href="#">
<IconInnerShadowTop className="!size-5" />
<span className="text-base font-semibold">Acme Inc.</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavDocuments items={data.documents} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />
</SidebarFooter>
</Sidebar>
)
}

30
app/admin/site-header.tsx Normal file
View File

@ -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 (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium">Documents</h1>
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" asChild size="sm" className="hidden sm:flex">
<a
href="https://github.com/shadcn-ui/ui/tree/main/apps/v4/app/(examples)/dashboard"
rel="noopener noreferrer"
target="_blank"
className="dark:text-foreground"
>
GitHub
</a>
</Button>
</div>
</div>
</header>
)
}

View File

@ -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<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false)
const [createUser, { loading }] = useMutation(CREATE_USER)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
username: "",
email: "",
password: "",
role: "USER" as const,
},
})
async function onSubmit(values: z.infer<typeof schema>) {
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 (
<Form {...form} >
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
{error && (
<div className="text-sm text-red-600 bg-red-50 p-3 rounded-md">
{error}
</div>
)}
<div className="grid gap-4">
<div className="grid gap-3">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input id="username" placeholder="请输入用户名" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input id="email" placeholder="请输入邮箱" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={(value) => field.onChange(value)}
>
<SelectTrigger id="role" className="w-full">
<SelectValue placeholder="选择用户角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN"></SelectItem>
<SelectItem value="USER"></SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel> *</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={generateRandomPassword}
disabled={isGenerating}
>
{isGenerating ? (
<>
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
'重新生成'
)}
</Button>
</div>
<FormControl>
<div className="flex gap-2">
<Input id="password" placeholder="请输入密码" {...field} required />
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
navigator.clipboard.writeText(field.value)
// toast.success('密码已复制到剪贴板')
}}
disabled={!field.value}
>
<IconCircleCheckFilled className="h-4 w-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
</Button>
</DialogClose>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
'创建用户'
)}
</Button>
</DialogFooter>
</form>
</Form >
)
}

17
app/admin/users/page.tsx Normal file
View File

@ -0,0 +1,17 @@
"use client"
import { UserTable } from "./user-table"
export default function Page() {
return (
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<h1 className="text-2xl font-bold px-6">Users</h1>
<UserTable />
</div>
</div>
</div>
)
}

View File

@ -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 (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => (
<div className="w-48">
<span className="text-sm text-muted-foreground">
{row.original.email}
</span>
</div>
),
},
{
accessorKey: "role",
header: "Role",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.role}
</Badge>
</div>
),
},
{
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 (
<div className="w-40">
<div className="flex flex-row gap-2">
<span className="text-sm font-medium">
{date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
<Badge variant="secondary" className="text-xs w-fit">
{timeAgo}
</Badge>
</div>
</div>
);
},
},
{
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 (
<div className="w-40">
<div className="flex flex-row gap-2">
<span className="text-sm font-medium">
{date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
<Badge variant="outline" className="text-xs w-fit">
{timeAgo}
</Badge>
</div>
</div>
);
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
const GET_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 && <div>Loading...</div>}
{error && <div>Error: {error.message}</div>}
{data && <Tabel data={data} info={usersInfo} refetch={refetch} />}
</>
)
}
function Tabel({ data, refetch, info }: { data: any, refetch: any, info: any }) {
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
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 (
<Dialog>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<CreateUserForm />
</DialogContent>
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">All Users</SelectItem>
<SelectItem value="past-performance">Active Users</SelectItem>
<SelectItem value="key-personnel">Administrators</SelectItem>
<SelectItem value="focus-documents">Inactive Users</SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">All Users</TabsTrigger>
<TabsTrigger value="past-performance">
Active Users <Badge variant="secondary">{info?.usersInfo?.totalActiveUsers}</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Administrators <Badge variant="secondary">{info?.usersInfo?.totalAdminUsers}</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Inactive Users</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add User</span>
</Button>
</DialogTrigger>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
// onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={data?.users.map((user: any) => user.id) || []}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
</Dialog>
)
}
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<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="text-foreground w-fit px-0 text-left">
{item.username}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.username}</DrawerTitle>
<DrawerDescription>
User profile and activity information
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Login activity up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
User activity and login statistics for the last 6 months. This shows
the user's engagement patterns and system usage over time.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="name">Name</Label>
<Input id="name" defaultValue={item.username} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" defaultValue={item.email} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="role">Role</Label>
<Select defaultValue={item.role}>
<SelectTrigger id="role" className="w-full">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Administrator">Administrator</SelectItem>
<SelectItem value="Manager">Manager</SelectItem>
<SelectItem value="Developer">Developer</SelectItem>
<SelectItem value="Designer">Designer</SelectItem>
<SelectItem value="Analyst">Analyst</SelectItem>
<SelectItem value="Support">Support</SelectItem>
<SelectItem value="Guest">Guest</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@ -20,6 +20,7 @@ import { Slider } from "@/components/ui/slider"
import { useMapLocation, type LocationKey } from "@/hooks/use-map-location" import { useMapLocation, type LocationKey } from "@/hooks/use-map-location"
import { ThemeToggle } from "@/components/theme-toggle" import { ThemeToggle } from "@/components/theme-toggle"
import { useMapZoom } from "@/hooks/use-map-zoom" import { useMapZoom } from "@/hooks/use-map-zoom"
import { useUser } from "./user-context"
// This is sample data. // This is sample data.
const data = { const data = {
@ -48,11 +49,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { currentLocation, flyToLocation, isMapReady } = useMapLocation(); const { currentLocation, flyToLocation, isMapReady } = useMapLocation();
const { zoomToLocation, zoomIn, zoomOut, mapState } = useMapZoom(); const { zoomToLocation, zoomIn, zoomOut, mapState } = useMapZoom();
const { user } = useUser()
return ( return (
<Sidebar {...props}> <Sidebar {...props}>
<SidebarHeader className="h-16 border-b border-sidebar-border"> <SidebarHeader className="h-16 border-b border-sidebar-border">
<NavUser user={data.user} /> {
user ? <NavUser user={user} /> : null
}
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{/* <DatePicker /> */} {/* <DatePicker /> */}

27
app/client-provider.tsx Normal file
View File

@ -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 (
<ThemeProvider>
<ApolloProvider client={client}>
<MapProvider>
<UserProvider>
{children}
</UserProvider>
</MapProvider>
</ApolloProvider>
</ThemeProvider>
)
}

View File

@ -0,0 +1,6 @@
#version 300 es
precision highp float;
out vec4 color;
void main(){
color=vec4(1.,0.,0.,.5);
}

View File

@ -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);
};

View File

@ -1,10 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { TwentyFirstToolbar } from '@21st-extension/toolbar-next'; import { ClientProviders } from "./client-provider";
import { ReactPlugin } from '@21st-extension/react';
import { ThemeProvider } from '@/components/theme-provider';
import { MapProvider } from "./map-context";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -26,21 +23,15 @@ export default function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<ThemeProvider defaultTheme="dark" storageKey="mosaic-theme"> <ClientProviders>
<MapProvider> {children}
{children} </ClientProviders>
<TwentyFirstToolbar
config={{
plugins: [ReactPlugin],
}}
/>
</MapProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

132
app/login/login-form.tsx Normal file
View File

@ -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<string | null>(null);
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
username: "",
password: "",
},
})
async function onSubmit(values: z.infer<typeof schema>) {
try {
setIsLoading(true);
setError(null);
debugger
await login(values);
// clearMap();
router.push('/');
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败,请重试');
} finally {
setIsLoading(false);
}
}
return (
<Form {...form}>
<form className={cn("flex flex-col gap-6", className)} {...props} onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground text-sm text-balance">
</p>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<div className="grid gap-6">
<div className="grid gap-3">
<FormField
control={form.control}
name="username"
disabled={isLoading}
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input id="username" placeholder="请输入用户名" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="password"
disabled={isLoading}
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormLabel></FormLabel>
<a
href="#"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
</a>
</div>
<FormControl>
<Input id="password" type="password" placeholder="请输入密码" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "登录中..." : "登录"}
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
使
</span>
</div>
<Button variant="outline" className="w-full" disabled={isLoading}>
GitHub
</Button>
</div>
<div className="text-center text-sm">
{" "}
<a href="#" className="underline underline-offset-4">
</a>
</div>
</form>
</Form>
)
}

76
app/login/page.tsx Normal file
View File

@ -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 (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<a href="#" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" />
</div>
Acme Inc.
</a>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs text-center">
<p className="text-muted-foreground">...</p>
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<img
src="/placeholder.svg"
alt="Image"
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/>
</div>
</div>
);
}
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<a href="#" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" />
</div>
Acme Inc.
</a>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs">
<LoginForm />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<img
src="/placeholder.svg"
alt="Image"
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/>
</div>
</div>
);
}

View File

@ -16,6 +16,7 @@ interface MapContextType {
zoomOut: () => void zoomOut: () => void
zoomTo: (zoom: number) => void zoomTo: (zoom: number) => void
reset: () => void reset: () => void
clearMap: () => void
setTime: (date: Date) => void setTime: (date: Date) => void
isMapReady: boolean isMapReady: boolean
} }
@ -44,6 +45,12 @@ export function MapProvider({ children }: MapProviderProps) {
const setMap = (map: Map, layers: any[]) => { const setMap = (map: Map, layers: any[]) => {
// 如果已经有地图实例,先清理旧的
if (mapRef.current) {
console.log('Cleaning up previous map instance...');
mapRef.current = null;
}
// 监听视图变化事件 // 监听视图变化事件
// const view = map.getView(); // 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 = { const value: MapContextType = {
setTime, setTime,
mapRef, mapRef,
@ -143,6 +163,7 @@ export function MapProvider({ children }: MapProviderProps) {
zoomOut, zoomOut,
zoomTo, zoomTo,
reset, reset,
clearMap,
isMapReady isMapReady
} }

25
app/me/layout.tsx Normal file
View File

@ -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 (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
)
}

67
app/me/nav-main.tsx Normal file
View File

@ -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 (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
tooltip="Quick Create"
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
>
<IconCirclePlusFilled />
<span>Quick Create</span>
</SidebarMenuButton>
<Button
size="icon"
className="size-8 group-data-[collapsible=icon]:opacity-0"
variant="outline"
>
<IconMail />
<span className="sr-only">Inbox</span>
</Button>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<Link href={item.url}>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url} >
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup >
)
}

21
app/me/page.tsx Normal file
View File

@ -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 (
<div>
<h1>Me</h1>
</div>
)
}

164
app/me/sidebar.tsx Normal file
View File

@ -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<typeof Sidebar>) {
const [nav, setNav] = React.useState(data.navMain)
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<a href="#">
<IconInnerShadowTop className="!size-5" />
<span className="text-base font-semibold">Acme Inc.</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
</SidebarContent>
<SidebarFooter>
</SidebarFooter>
</Sidebar>
)
}

View File

@ -29,17 +29,12 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
import { User } from "@/types/user"
import { useUser } from "./user-context"
export function NavUser({ export function NavUser({ user }: { user: User }) {
user,
}: {
user: {
name: string
email: string
avatar: string
}
}) {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
const { logout } = useUser()
return ( return (
<SidebarMenu> <SidebarMenu>
@ -102,7 +97,7 @@ export function NavUser({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem> <DropdownMenuItem onClick={logout}>
<LogOut /> <LogOut />
Log out Log out
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -1,4 +1,5 @@
'use client' 'use client'
import { AppSidebar } from '@/app/app-sidebar' import { AppSidebar } from '@/app/app-sidebar'
import { import {
Breadcrumb, Breadcrumb,
@ -15,40 +16,19 @@ import {
import { MapComponent } from '@/components/map-component'; import { MapComponent } from '@/components/map-component';
import { ThemeToggle } from '@/components/theme-toggle'; import { ThemeToggle } from '@/components/theme-toggle';
import { Timeline } from '@/app/timeline'; import { Timeline } from '@/app/timeline';
import {
Home,
Search,
Settings,
User,
Play,
} from "lucide-react"
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTimeline } from '@/hooks/use-timeline'; import { useTimeline } from '@/hooks/use-timeline';
import { useEffect } from 'react' import { useEffect } from 'react'
import { useRadarTile } from '@/hooks/use-radartile' import { useRadarTile } from '@/hooks/use-radartile'
export default function Page() { 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 now = new Date();
const startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7天前 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 endDate = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000); // 3天后
const { setTime } = useTimeline() const { setTime } = useTimeline()
const { fetchRadarTile } = useRadarTile({}) const { imgBitmap, fetchRadarTile } = useRadarTile({})
useEffect(() => {
fetchRadarTile("http://127.0.0.1:3050/test")
}, [])
return ( return (
<SidebarProvider> <SidebarProvider>
@ -69,7 +49,7 @@ export default function Page() {
</div> </div>
</header> </header>
<div className="relative h-full w-full flex flex-col"> <div className="relative h-full w-full flex flex-col">
<MapComponent /> <MapComponent imgBitmap={imgBitmap} />
<Timeline <Timeline
className={ className={
cn( cn(

View File

@ -1006,6 +1006,38 @@ export const Timeline: React.FC<Props> = ({
gl.viewport(0, 0, actualWidth, actualHeight); gl.viewport(0, 0, actualWidth, actualHeight);
render(); 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]); }, [canvasRef.current]);
return ( return (

416
app/user-context.tsx Normal file
View File

@ -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<void>
register: (data: RegisterData) => Promise<void>
logout: () => void
refreshToken: () => Promise<void>
resetApolloCache: () => void
refetchUser: () => Promise<any>
}
const UserContext = createContext<UserContextType | undefined>(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<any> | null = null;
export function setGlobalApolloClient(client: ApolloClient<any>) {
globalApolloClient = client;
}
export function UserProvider({ children }: UserProviderProps) {
const [authState, setAuthState] = useState<AuthState>({
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 (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
)
}
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()
}

133
components/colorbar.tsx Normal file
View File

@ -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<HTMLCanvasElement>(null)
const [isHovered, setIsHovered] = useState(false)
const [hoverValue, setHoverValue] = useState<number | null>(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<HTMLCanvasElement>) => {
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 (
<div className="relative">
<canvas
ref={canvasRef}
className="cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ display: 'block' }}
/>
{showLabels && (
<div className="flex justify-between text-xs text-gray-600 mt-1">
<span>{formatValue(minValue)}</span>
<span>{formatValue(maxValue)}</span>
</div>
)}
{isHovered && hoverValue !== null && (
<div
className="absolute bg-black text-white text-xs px-2 py-1 rounded pointer-events-none z-10"
style={{
left: `${(hoverValue - minValue) / (maxValue - minValue) * width}px`,
top: '-30px',
transform: 'translateX(-50%)'
}}
>
{formatValue(hoverValue)}
</div>
)}
</div>
)
}

View File

@ -1,64 +1,44 @@
'use client' 'use client'
import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef } from 'react' import maplibregl, { CustomLayerInterface, CustomRenderMethodInput, } from 'maplibre-gl'
import maplibregl, { CustomLayerInterface, CustomRenderMethodInput, createTileMesh, Projection } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css' import 'maplibre-gl/dist/maplibre-gl.css'
import { useMap } from '@/app/map-context' import { useMap } from '@/app/map-context'
import { useMapLocation } from '@/hooks/use-map-location' import { useMapLocation } from '@/hooks/use-map-location'
import { createOptimalWorldMesh, getSubdivisionRecommendation, detectPerformanceLevel, createOptimalRegionMesh, RegionMeshPresets } from '@/lib/tile-mesh' import { getSubdivisionRecommendation, detectPerformanceLevel, RegionMeshPresets } from '@/lib/tile-mesh'
import { useRadarTile } from '@/hooks/use-radartile' import { createColorMap, ColorMapType, } from '@/lib/color-maps'
import { Colorbar } from './colorbar'
interface MapComponentProps { interface MapComponentProps {
style?: string style?: string
center?: [number, number] center?: [number, number]
zoom?: 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({ export function MapComponent({
style = 'https://api.maptiler.com/maps/019817f1-82a8-7f37-901d-4bedf68b27fb/style.json?key=hj3fxRdwF9KjEsBq8sYI', style = 'https://api.maptiler.com/maps/019817f1-82a8-7f37-901d-4bedf68b27fb/style.json?key=hj3fxRdwF9KjEsBq8sYI',
// center = [103.851959, 1.290270], // center = [103.851959, 1.290270],
// zoom = 11 // zoom = 11
imgBitmap: propImgBitmap,
colorMapType = 'heatmap',
onColorMapChange
}: MapComponentProps) { }: MapComponentProps) {
const mapContainer = useRef<HTMLDivElement>(null) const mapContainer = useRef<HTMLDivElement>(null)
const { setMap } = useMap() const { setMap } = useMap()
const { location } = useMapLocation() const { location } = useMapLocation()
const { radarTileRef } = useRadarTile() const imgBitmap = propImgBitmap
const texRef = useRef<WebGLTexture | null>(null) const texRef = useRef<WebGLTexture | null>(null)
const lutTexRef = useRef<WebGLTexture | null>(null) const lutTexRef = useRef<WebGLTexture | null>(null)
const glRef = useRef<WebGL2RenderingContext | null>(null) const glRef = useRef<WebGL2RenderingContext | null>(null)
const customLayerRef = useRef<CustomGlLayer | null>(null)
const [isReady, setIsReady] = useState<boolean>(false)
const [currentColorMapType, setCurrentColorMapType] = useState<ColorMapType>(colorMapType)
useEffect(() => { useEffect(() => {
if (!mapContainer.current) return 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({ const map = new maplibregl.Map({
container: mapContainer.current, container: mapContainer.current,
style: style, style: style,
@ -75,15 +55,6 @@ export function MapComponent({
type: 'globe' 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 = { const customGlLayer: CustomGlLayer = {
id: 'player', id: 'player',
type: 'custom', type: 'custom',
@ -92,6 +63,7 @@ export function MapComponent({
prerender(gl: WebGLRenderingContext | WebGL2RenderingContext, { shaderData }: CustomRenderMethodInput) { prerender(gl: WebGLRenderingContext | WebGL2RenderingContext, { shaderData }: CustomRenderMethodInput) {
if (!this.program) { if (!this.program) {
glRef.current = gl as WebGL2RenderingContext; glRef.current = gl as WebGL2RenderingContext;
if (!(gl instanceof WebGL2RenderingContext)) { if (!(gl instanceof WebGL2RenderingContext)) {
@ -123,9 +95,22 @@ export function MapComponent({
in vec2 v_tex_coord; in vec2 v_tex_coord;
void main() { 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)); 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) 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_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_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// 初始化时不更新纹理,等待 useEffect 中的更新
gl.bindTexture(gl.TEXTURE_2D, null); gl.bindTexture(gl.TEXTURE_2D, null);
this.tex = tex; this.tex = tex;
texRef.current = tex; texRef.current = tex;
// 创建 LUT 纹理 // 创建 LUT 纹理
const lutTex = createLutTexture(gl); const lutTex = createLutTexture(gl, currentColorMapType);
if (!lutTex) return; if (!lutTex) return;
this.lutTex = lutTex; this.lutTex = lutTex;
@ -263,6 +250,8 @@ export function MapComponent({
this.vao = vao; this.vao = vao;
this.vertexBuffer = vertexBuffer; this.vertexBuffer = vertexBuffer;
this.indexBuffer = indexBuffer; this.indexBuffer = indexBuffer;
setIsReady(true)
} }
// 只在缩放级别变化时更新网格数据 // 只在缩放级别变化时更新网格数据
@ -301,10 +290,15 @@ export function MapComponent({
this.lastZoom = currentZoom; this.lastZoom = currentZoom;
} }
} }
// 移除这里的纹理更新,避免循环更新
}, },
onAdd: function (map: maplibregl.Map, gl: WebGL2RenderingContext) { onAdd: function (map: maplibregl.Map, gl: WebGL2RenderingContext) {
console.log('Custom layer added'); console.log('Custom layer added');
customLayerRef.current = this;
}, },
onRemove: function (map: maplibregl.Map, gl: WebGL2RenderingContext) { onRemove: function (map: maplibregl.Map, gl: WebGL2RenderingContext) {
@ -417,14 +411,35 @@ export function MapComponent({
setMap(map, []) 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]) }, [mapContainer])
useEffect(() => { useEffect(() => {
if (radarTileRef.current.imgBitmap && texRef.current) { if (imgBitmap && texRef.current) {
const gl = glRef.current const gl = glRef.current
if (!gl) return; if (!gl) return;
debugger console.log('Updating texture with imgBitmap:', imgBitmap);
gl.bindTexture(gl.TEXTURE_2D, texRef.current) gl.bindTexture(gl.TEXTURE_2D, texRef.current)
@ -433,29 +448,68 @@ export function MapComponent({
gl.texImage2D( gl.texImage2D(
gl.TEXTURE_2D, gl.TEXTURE_2D,
0, 0,
gl.RED, // 内部格式:单通道红色 gl.RGBA, // 内部格式使用RGBA兼容性更好
gl.RGBA, // 数据格式ImageBitmap总是RGBA gl.RGBA, // 数据格式ImageBitmap总是RGBA
gl.UNSIGNED_BYTE, 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_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 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_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.bindTexture(gl.TEXTURE_2D, null); 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 ( return (
<div <div className="relative w-full h-full" style={{ minHeight: '400px' }}>
ref={mapContainer} <div
className="w-full h-full" ref={mapContainer}
style={{ minHeight: '400px' }} className="w-full h-full"
/> />
{/* Colorbar 在右下角 */}
<div className="absolute bottom-4 right-4 bg-white/90 backdrop-blur-sm rounded-lg p-3 shadow-lg border">
<div className="text-xs text-gray-600 mb-2 font-medium"></div>
<Colorbar
colorMapType={currentColorMapType}
width={180}
height={16}
minValue={0}
maxValue={75}
unit="dBZ"
/>
</div>
</div>
) )
} }
@ -477,14 +531,9 @@ interface CustomGlLayer extends CustomLayerInterface {
function createLutTexture(gl: WebGL2RenderingContext) { function createLutTexture(gl: WebGL2RenderingContext, colorMapType: ColorMapType = 'radar') {
const lut = new Uint8Array(256 * 4); // 使用统一的色标创建函数
for (let i = 0; i < 256; i++) { const lut = createColorMap(colorMapType);
lut[i * 4] = i;
lut[i * 4 + 1] = i;
lut[i * 4 + 2] = i;
lut[i * 4 + 3] = 255;
}
const tex = gl.createTexture() const tex = gl.createTexture()
if (!tex) { if (!tex) {

353
components/ui/chart.tsx Normal file
View File

@ -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<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
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 (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
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 (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

143
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -1,60 +1,135 @@
"use client"
import * as React from "react" import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface DrawerProps { function Drawer({
children: React.ReactNode ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
} }
interface DrawerTriggerProps { function DrawerTrigger({
children: React.ReactNode ...props
asChild?: boolean }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
} }
interface DrawerContentProps { function DrawerPortal({
children: React.ReactNode ...props
className?: string }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
} }
interface DrawerHeaderProps { function DrawerClose({
children: React.ReactNode ...props
className?: string }: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
} }
interface DrawerTitleProps { function DrawerOverlay({
children: React.ReactNode className,
className?: string ...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
} }
const Drawer: React.FC<DrawerProps> = ({ children }) => { function DrawerContent({
return <div className="drawer">{children}</div> className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
} }
const DrawerTrigger: React.FC<DrawerTriggerProps> = ({ children, asChild }) => { function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div className="drawer-trigger">{children}</div> return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
} }
const DrawerContent: React.FC<DrawerContentProps> = ({ children, className }) => { function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div className={cn("drawer-content bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700", className)}> <div
{children} data-slot="drawer-footer"
</div> className={cn("mt-auto flex flex-col gap-2 p-4", className)}
) {...props}
/>
)
} }
const DrawerHeader: React.FC<DrawerHeaderProps> = ({ children, className }) => { function DrawerTitle({
return ( className,
<div className={cn("drawer-header p-4 border-b border-gray-200 dark:border-gray-700", className)}> ...props
{children} }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
</div> return (
) <DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
} }
const DrawerTitle: React.FC<DrawerTitleProps> = ({ children, className }) => { function DrawerDescription({
return ( className,
<h3 className={cn("text-lg font-semibold", className)}> ...props
{children} }: React.ComponentProps<typeof DrawerPrimitive.Description>) {
</h3> return (
) <DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
} }
export { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

167
components/ui/form.tsx Normal file
View File

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

116
components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

66
components/ui/tabs.tsx Normal file
View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,73 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

47
components/ui/toggle.tsx Normal file
View File

@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@ -12,7 +12,6 @@ interface RadarTileStatus {
} }
interface RadarTile { interface RadarTile {
imgBitmap: ImageBitmap | null;
needRefresh: boolean; needRefresh: boolean;
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
@ -22,35 +21,34 @@ interface RadarTile {
export function useRadarTile({ export function useRadarTile({
}: UseRadarTileOptions = {}) { }: UseRadarTileOptions = {}) {
const [imgBitmap, setImgBitmap] = useState<ImageBitmap | null>(null)
const radarTileRef = useRef<RadarTile>({ const radarTileRef = useRef<RadarTile>({
imgBitmap: null,
needRefresh: false, needRefresh: false,
isLoading: false, isLoading: false,
isError: false, isError: false,
url: null, url: null,
}) })
const fetchRadarTile = async (url: string) => {
const fetchRadarTile = useCallback(async (url: string) => {
radarTileRef.current.needRefresh = true radarTileRef.current.needRefresh = true
radarTileRef.current.isError = false radarTileRef.current.isError = false
radarTileRef.current.url = url
}, [])
useEffect(() => { fetch(url).then(async (resp) => {
if (radarTileRef.current.needRefresh) { radarTileRef.current.url = url
if (radarTileRef.current.url) { const blob = await resp.blob()
fetch(radarTileRef.current.url).then(async (resp) => { const newImgBitmap = await createImageBitmap(blob)
const blob = await resp.blob() console.log('Created new ImageBitmap:', newImgBitmap);
const imgBitmap = await createImageBitmap(blob) setImgBitmap(newImgBitmap) // 使用 setState 更新状态
radarTileRef.current.imgBitmap = imgBitmap }).catch((err) => {
}) radarTileRef.current.isError = true
} console.error(err)
} })
}, [radarTileRef.current.needRefresh, fetchRadarTile]) }
return { return {
radarTileRef, imgBitmap,
fetchRadarTile, fetchRadarTile,
} }
} }

37
lib/apollo-client.ts Normal file
View File

@ -0,0 +1,37 @@
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
const TOKEN_KEY = 'auth_token';
const httpLink = createHttpLink({
uri: "http://127.0.0.1:3050/graphql",
});
const authLink = setContext((_, { headers }) => {
// 从 localStorage 获取 token
const token = typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null;
// 如果有 token添加到 headers 中
if (token) {
return {
headers: {
...headers,
authorization: `Bearer ${token}`,
}
};
}
// 如果没有 token返回原始 headers
return {
headers: {
...headers,
}
};
});
export const createApolloClient = () => {
return new ApolloClient({
link: from([authLink, httpLink]),
cache: new InMemoryCache(),
});
};

225
lib/color-maps.ts Normal file
View File

@ -0,0 +1,225 @@
// 色标函数集合
export type ColorMapType = 'radar' | 'rainbow' | 'heatmap' | 'viridis' | 'plasma' | 'grayscale';
// 雷达色标 (深蓝到红色,类似气象雷达)
export function createRadarColorMap(): Uint8Array {
const lut = new Uint8Array(256 * 4);
for (let i = 0; i < 256; i++) {
const t = i / 255.0;
let r, g, b;
// 重新设计颜色分布让30和60成为重要节点
// 0-30: 深蓝到蓝色 (对应t的0-0.4)
// 30-60: 蓝色到绿色 (对应t的0.4-0.7)
// 60-75: 绿色到黄色到红色 (对应t的0.7-1.0)
if (t < 0.2) {
// 深蓝到蓝色 (0-30 dBZ)
const localT = t / 0.4;
r = 0;
g = 0;
b = Math.floor(50 + localT * 205); // 深蓝(50,0,255) 到 蓝色(0,0,255)
} else if (t < 0.5) {
// 蓝色到绿色 (30-60 dBZ)
const localT = (t - 0.4) / 0.3;
r = 0;
g = Math.floor(localT * 255);
b = Math.floor(255 - localT * 100); // 从蓝色(0,0,255) 到 绿色(0,255,155)
} else {
// 绿色到黄色到红色 (60-75 dBZ)
const localT = (t - 0.7) / 0.3;
if (localT < 0.5) {
// 绿色到黄色
const yellowT = localT / 0.5;
r = Math.floor(yellowT * 255);
g = 255;
b = Math.floor(155 - yellowT * 155);
} else {
// 黄色到红色
const redT = (localT - 0.5) / 0.5;
r = 255;
g = Math.floor(255 - redT * 255);
b = 0;
}
}
lut[i * 4] = r;
lut[i * 4 + 1] = g;
lut[i * 4 + 2] = b;
lut[i * 4 + 3] = 255;
}
return lut;
}
// 彩虹色标 (完整的彩虹光谱)
export function createRainbowColorMap(): Uint8Array {
const lut = new Uint8Array(256 * 4);
for (let i = 0; i < 256; i++) {
const t = i / 255.0;
// 使用 HSL 颜色空间创建平滑的彩虹渐变
const hue = (1 - t) * 240; // 从蓝色到红色
// 将 HSL 转换为 RGB
const h = hue / 60;
const s = 1.0;
const l = 0.5;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(h % 2 - 1));
const m = l - c / 2;
let r, g, b;
if (h < 1) {
r = c; g = x; b = 0;
} else if (h < 2) {
r = x; g = c; b = 0;
} else if (h < 3) {
r = 0; g = c; b = x;
} else if (h < 4) {
r = 0; g = x; b = c;
} else if (h < 5) {
r = x; g = 0; b = c;
} else {
r = c; g = 0; b = x;
}
lut[i * 4] = Math.floor((r + m) * 255);
lut[i * 4 + 1] = Math.floor((g + m) * 255);
lut[i * 4 + 2] = Math.floor((b + m) * 255);
lut[i * 4 + 3] = 255;
}
return lut;
}
// 热力图色标 (黑色到红色到黄色到白色)
export function createHeatmapColorMap(): Uint8Array {
const lut = new Uint8Array(256 * 4);
for (let i = 0; i < 256; i++) {
const t = i / 255.0;
let r, g, b;
if (t < 0.33) {
// 黑色到红色 (0-0.33)
const localT = t / 0.33;
r = Math.floor(localT * 255);
g = 0;
b = 0;
} else if (t < 0.66) {
// 红色到黄色 (0.33-0.66)
const localT = (t - 0.33) / 0.33;
r = 255;
g = Math.floor(localT * 255);
b = 0;
} else {
// 黄色到白色 (0.66-1.0)
const localT = (t - 0.66) / 0.34;
r = 255;
g = 255;
b = Math.floor(localT * 255);
}
lut[i * 4] = r;
lut[i * 4 + 1] = g;
lut[i * 4 + 2] = b;
lut[i * 4 + 3] = 255;
}
return lut;
}
// Viridis 色标 (科学可视化常用)
export function createViridisColorMap(): Uint8Array {
const lut = new Uint8Array(256 * 4);
for (let i = 0; i < 256; i++) {
const t = i / 255.0;
// Viridis 色标的 RGB 值 (从 matplotlib)
const r = Math.floor(0.267004 + 0.004874 * Math.cos(6.28318 * (0.5 + t)) + 0.213142 * Math.cos(12.5664 * (0.5 + t)) + 0.233637 * Math.cos(18.8496 * (0.5 + t)) + 0.271376 * Math.cos(25.1327 * (0.5 + t)) + 0.327439 * Math.cos(31.4159 * (0.5 + t)) + 0.383443 * Math.cos(37.6991 * (0.5 + t)) + 0.419423 * Math.cos(43.9823 * (0.5 + t)) + 0.425422 * Math.cos(50.2655 * (0.5 + t)) + 0.401395 * Math.cos(56.5487 * (0.5 + t)) + 0.347318 * Math.cos(62.8319 * (0.5 + t)) + 0.263257 * Math.cos(69.1150 * (0.5 + t)) + 0.149456 * Math.cos(75.3982 * (0.5 + t)) + 0.007653 * Math.cos(81.6814 * (0.5 + t)) - 0.149456 * Math.cos(87.9646 * (0.5 + t)) - 0.263257 * Math.cos(94.2478 * (0.5 + t)) - 0.347318 * Math.cos(100.531 * (0.5 + t)) - 0.401395 * Math.cos(106.814 * (0.5 + t)) - 0.425422 * Math.cos(113.097 * (0.5 + t)) - 0.419423 * Math.cos(119.381 * (0.5 + t)) - 0.383443 * Math.cos(125.664 * (0.5 + t)) - 0.327439 * Math.cos(131.947 * (0.5 + t)) - 0.271376 * Math.cos(138.230 * (0.5 + t)) - 0.233637 * Math.cos(144.513 * (0.5 + t)) - 0.213142 * Math.cos(150.796 * (0.5 + t)) - 0.004874 * Math.cos(157.080 * (0.5 + t)) + 0.267004 * Math.cos(163.363 * (0.5 + t)));
const g = Math.floor(0.004874 + 0.004874 * Math.cos(6.28318 * (0.5 + t)) + 0.213142 * Math.cos(12.5664 * (0.5 + t)) + 0.233637 * Math.cos(18.8496 * (0.5 + t)) + 0.271376 * Math.cos(25.1327 * (0.5 + t)) + 0.327439 * Math.cos(31.4159 * (0.5 + t)) + 0.383443 * Math.cos(37.6991 * (0.5 + t)) + 0.419423 * Math.cos(43.9823 * (0.5 + t)) + 0.425422 * Math.cos(50.2655 * (0.5 + t)) + 0.401395 * Math.cos(56.5487 * (0.5 + t)) + 0.347318 * Math.cos(62.8319 * (0.5 + t)) + 0.263257 * Math.cos(69.1150 * (0.5 + t)) + 0.149456 * Math.cos(75.3982 * (0.5 + t)) + 0.007653 * Math.cos(81.6814 * (0.5 + t)) - 0.149456 * Math.cos(87.9646 * (0.5 + t)) - 0.263257 * Math.cos(94.2478 * (0.5 + t)) - 0.347318 * Math.cos(100.531 * (0.5 + t)) - 0.401395 * Math.cos(106.814 * (0.5 + t)) - 0.425422 * Math.cos(113.097 * (0.5 + t)) - 0.419423 * Math.cos(119.381 * (0.5 + t)) - 0.383443 * Math.cos(125.664 * (0.5 + t)) - 0.327439 * Math.cos(131.947 * (0.5 + t)) - 0.271376 * Math.cos(138.230 * (0.5 + t)) - 0.233637 * Math.cos(144.513 * (0.5 + t)) - 0.213142 * Math.cos(150.796 * (0.5 + t)) - 0.004874 * Math.cos(157.080 * (0.5 + t)) + 0.004874 * Math.cos(163.363 * (0.5 + t)));
const b = Math.floor(0.329415 + 0.004874 * Math.cos(6.28318 * (0.5 + t)) + 0.213142 * Math.cos(12.5664 * (0.5 + t)) + 0.233637 * Math.cos(18.8496 * (0.5 + t)) + 0.271376 * Math.cos(25.1327 * (0.5 + t)) + 0.327439 * Math.cos(31.4159 * (0.5 + t)) + 0.383443 * Math.cos(37.6991 * (0.5 + t)) + 0.419423 * Math.cos(43.9823 * (0.5 + t)) + 0.425422 * Math.cos(50.2655 * (0.5 + t)) + 0.401395 * Math.cos(56.5487 * (0.5 + t)) + 0.347318 * Math.cos(62.8319 * (0.5 + t)) + 0.263257 * Math.cos(69.1150 * (0.5 + t)) + 0.149456 * Math.cos(75.3982 * (0.5 + t)) + 0.007653 * Math.cos(81.6814 * (0.5 + t)) - 0.149456 * Math.cos(87.9646 * (0.5 + t)) - 0.263257 * Math.cos(94.2478 * (0.5 + t)) - 0.347318 * Math.cos(100.531 * (0.5 + t)) - 0.401395 * Math.cos(106.814 * (0.5 + t)) - 0.425422 * Math.cos(113.097 * (0.5 + t)) - 0.419423 * Math.cos(119.381 * (0.5 + t)) - 0.383443 * Math.cos(125.664 * (0.5 + t)) - 0.327439 * Math.cos(131.947 * (0.5 + t)) - 0.271376 * Math.cos(138.230 * (0.5 + t)) - 0.233637 * Math.cos(144.513 * (0.5 + t)) - 0.213142 * Math.cos(150.796 * (0.5 + t)) - 0.004874 * Math.cos(157.080 * (0.5 + t)) + 0.329415 * Math.cos(163.363 * (0.5 + t)));
lut[i * 4] = Math.max(0, Math.min(255, r));
lut[i * 4 + 1] = Math.max(0, Math.min(255, g));
lut[i * 4 + 2] = Math.max(0, Math.min(255, b));
lut[i * 4 + 3] = 255;
}
return lut;
}
// Plasma 色标 (另一种科学可视化色标)
export function createPlasmaColorMap(): Uint8Array {
const lut = new Uint8Array(256 * 4);
for (let i = 0; i < 256; i++) {
const t = i / 255.0;
// 简化的 Plasma 色标
const r = Math.floor(255 * (0.5 + 0.5 * Math.cos(2 * Math.PI * (t - 0.5))));
const g = Math.floor(255 * (0.5 + 0.5 * Math.cos(2 * Math.PI * (t - 0.25))));
const b = Math.floor(255 * (0.5 + 0.5 * Math.cos(2 * Math.PI * t)));
lut[i * 4] = r;
lut[i * 4 + 1] = g;
lut[i * 4 + 2] = b;
lut[i * 4 + 3] = 255;
}
return lut;
}
// 灰度色标
export function createGrayscaleColorMap(): Uint8Array {
const lut = new Uint8Array(256 * 4);
for (let i = 0; i < 256; i++) {
lut[i * 4] = i; // R
lut[i * 4 + 1] = i; // G
lut[i * 4 + 2] = i; // B
lut[i * 4 + 3] = 255; // A
}
return lut;
}
// 色标工厂函数
export function createColorMap(type: ColorMapType): Uint8Array {
switch (type) {
case 'radar':
return createRadarColorMap();
case 'rainbow':
return createRainbowColorMap();
case 'heatmap':
return createHeatmapColorMap();
case 'viridis':
return createViridisColorMap();
case 'plasma':
return createPlasmaColorMap();
case 'grayscale':
return createGrayscaleColorMap();
default:
return createRadarColorMap();
}
}
// 获取所有可用的色标类型
export function getAvailableColorMaps(): { value: ColorMapType; label: string }[] {
return [
{ value: 'radar', label: '雷达色标' },
{ value: 'rainbow', label: '彩虹色标' },
{ value: 'heatmap', label: '热力图色标' },
{ value: 'viridis', label: 'Viridis色标' },
{ value: 'plasma', label: 'Plasma色标' },
{ value: 'grayscale', label: '灰度色标' }
];
}

View File

@ -337,7 +337,7 @@ export const RegionMeshPresets = {
/** 中国大陆区域 */ /** 中国大陆区域 */
china: (z: number, subdivisions?: number) => createSubdividedTileMesh({ china: (z: number, subdivisions?: number) => createSubdividedTileMesh({
bounds: [73, 18, 135, 54], bounds: [65.24686921730095, 11.90274236858339, 138.85323419021077, 55.34323805611308],
z, z,
subdivisions subdivisions
}), }),

1563
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,13 @@
"dependencies": { "dependencies": {
"@21st-extension/react": "^0.5.14", "@21st-extension/react": "^0.5.14",
"@21st-extension/toolbar-next": "^0.5.14", "@21st-extension/toolbar-next": "^0.5.14",
"@apollo/client": "^3.13.8",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.2.0",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
@ -22,11 +28,18 @@
"@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tabler/icons-react": "^3.34.1",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dnd-kit": "^0.0.2",
"framer-motion": "^12.23.6", "framer-motion": "^12.23.6",
"graphql": "^16.11.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"maplibre-gl": "^5.6.1", "maplibre-gl": "^5.6.1",
"next": "15.4.1", "next": "15.4.1",
@ -35,7 +48,14 @@
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.8.0", "react-day-picker": "^9.8.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"tailwind-merge": "^3.3.1" "react-hook-form": "^7.61.1",
"recharts": "^2.15.4",
"sonner": "^2.0.6",
"swr": "^2.3.4",
"tabler": "^1.0.0",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^4.0.10"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

34
types/user.ts Normal file
View File

@ -0,0 +1,34 @@
export interface User {
id: string
email: string
name?: string
avatar?: string
role?: string
}
export interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
}
export interface LoginCredentials {
username: string
password: string
}
export interface RegisterData {
username: string
password: string
name?: string
}
export interface JWTPayload {
sub: string
email: string
name?: string
role?: string
exp: number
iat: number
}