mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
refactor: remove data-table component and related functionality
refactor: update sidebar item interface to use IconType refactor: modify nav-main and nav-ucenter components to use IconType refactor: delete unused nav-secondary component refactor: clean up user profile page logic and improve avatar handling refactor: remove section-cards component refactor: add icon type definition for better type safety
This commit is contained in:
@ -14,12 +14,16 @@ const axiosClient = axios.create({
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
function isBrowserFormData(v: any) {
|
function isBrowserFormData(v: unknown): v is FormData {
|
||||||
return typeof FormData !== 'undefined' && v instanceof FormData
|
return typeof FormData !== 'undefined' && v instanceof FormData
|
||||||
}
|
}
|
||||||
// node form-data (form-data package) heuristic
|
// node form-data (form-data package) heuristic
|
||||||
function isNodeFormData(v: any) {
|
function isNodeFormData(v: unknown): v is { getHeaders: (...args: unknown[]) => Record<string, string>; pipe: (...args: unknown[]) => unknown } {
|
||||||
return v && typeof v.getHeaders === 'function' && typeof v.pipe === 'function'
|
return Boolean(
|
||||||
|
v &&
|
||||||
|
typeof (v as { getHeaders?: unknown }).getHeaders === 'function' &&
|
||||||
|
typeof (v as { pipe?: unknown }).pipe === 'function'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
axiosClient.interceptors.request.use((config) => {
|
axiosClient.interceptors.request.use((config) => {
|
||||||
|
@ -32,7 +32,7 @@ export default function ConsoleLayout({
|
|||||||
} else {
|
} else {
|
||||||
setTitle("Title");
|
setTitle("Title");
|
||||||
}
|
}
|
||||||
}, [pathname])
|
}, [pathname, sideBarItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { useToLogin, useToUserProfile } from "@/hooks/use-route";
|
import { useToLogin, useToUserProfile } from "@/hooks/use-route";
|
||||||
import { User } from "@/models/user";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -8,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar";
|
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { useToLogin, useToUserProfile } from "@/hooks/use-route";
|
import { useToLogin, useToUserProfile } from "@/hooks/use-route";
|
||||||
import { User } from "@/models/user";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -13,7 +12,7 @@ import { createComment, deleteComment, getComment, listComments, updateComment }
|
|||||||
import { OrderBy } from "@/models/common";
|
import { OrderBy } from "@/models/common";
|
||||||
import { formatDateTime } from "@/utils/common/datetime";
|
import { formatDateTime } from "@/utils/common/datetime";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar";
|
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { CommentInput } from "./comment-input";
|
import { CommentInput } from "./comment-input";
|
||||||
import { CommentItem } from "./comment-item";
|
import { CommentItem } from "./comment-item";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
@ -33,9 +32,6 @@ export function CommentSection(
|
|||||||
const [page, setPage] = useState(1); // 当前页码
|
const [page, setPage] = useState(1); // 当前页码
|
||||||
const [totalCommentCount, setTotalCommentCount] = useState(totalCount); // 评论总数
|
const [totalCommentCount, setTotalCommentCount] = useState(totalCount); // 评论总数
|
||||||
const [needLoadMore, setNeedLoadMore] = useState(true); // 是否需要加载更多,当最后一次获取的评论数小于分页大小时设为false
|
const [needLoadMore, setNeedLoadMore] = useState(true); // 是否需要加载更多,当最后一次获取的评论数小于分页大小时设为false
|
||||||
|
|
||||||
// 获取登录用户信息
|
|
||||||
const {user} = useAuth();
|
|
||||||
// 加载0/顶层评论
|
// 加载0/顶层评论
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listComments({
|
listComments({
|
||||||
|
@ -1,28 +1,232 @@
|
|||||||
|
"use client"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import Image from "next/image"
|
||||||
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import ReactCrop, { Crop } from 'react-image-crop';
|
||||||
|
import 'react-image-crop/dist/ReactCrop.css'
|
||||||
|
|
||||||
|
export function ImageCropper({
|
||||||
|
image,
|
||||||
|
onCropped,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
image: File | Blob | null;
|
||||||
|
onCropped: (blob: Blob) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}) {
|
||||||
|
const [crop, setCrop] = useState<Partial<Crop>>({
|
||||||
|
unit: '%',
|
||||||
|
x: 25,
|
||||||
|
y: 25,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [imageSrc, setImageSrc] = useState<string>("")
|
||||||
|
const [open, setOpen] = useState<boolean>(false)
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||||
|
const objectUrlRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 清理旧的 objectURL
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current)
|
||||||
|
objectUrlRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
setImageSrc("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof image === "string") {
|
||||||
|
setImageSrc(image)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = URL.createObjectURL(image as Blob)
|
||||||
|
objectUrlRef.current = url
|
||||||
|
setImageSrc(url)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("createObjectURL failed", err)
|
||||||
|
setImageSrc("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current)
|
||||||
|
objectUrlRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [image])
|
||||||
|
|
||||||
|
const onImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
imgRef.current = e.currentTarget
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getCroppedBlob = useCallback(async (): Promise<Blob | null> => {
|
||||||
|
const img = imgRef.current
|
||||||
|
if (!img || !crop) return null
|
||||||
|
|
||||||
|
// 计算渲染像素上的裁剪区域(支持 '%' 或 'px')
|
||||||
|
const unitIsPercent = (crop.unit ?? '%') === '%'
|
||||||
|
const renderWidth = img.width
|
||||||
|
const renderHeight = img.height
|
||||||
|
|
||||||
|
const sxRender = unitIsPercent ? ((crop.x ?? 0) / 100) * renderWidth : (crop.x ?? 0)
|
||||||
|
const syRender = unitIsPercent ? ((crop.y ?? 0) / 100) * renderHeight : (crop.y ?? 0)
|
||||||
|
const swRender = unitIsPercent ? ((crop.width ?? 0) / 100) * renderWidth : (crop.width ?? 0)
|
||||||
|
const shRender = unitIsPercent ? ((crop.height ?? 0) / 100) * renderHeight : (crop.height ?? 0)
|
||||||
|
|
||||||
|
// 把渲染像素坐标映射到原始图片像素(naturalWidth/naturalHeight)
|
||||||
|
const scaleX = img.naturalWidth / renderWidth
|
||||||
|
const scaleY = img.naturalHeight / renderHeight
|
||||||
|
const sx = Math.round(sxRender * scaleX)
|
||||||
|
const sy = Math.round(syRender * scaleY)
|
||||||
|
const sw = Math.max(1, Math.round(swRender * scaleX))
|
||||||
|
const sh = Math.max(1, Math.round(shRender * scaleY))
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = sw
|
||||||
|
canvas.height = sh
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return null
|
||||||
|
ctx.clearRect(0, 0, sw, sh)
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh)
|
||||||
|
|
||||||
|
return await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob((b) => resolve(b), 'image/png', 0.95)
|
||||||
|
})
|
||||||
|
}, [crop])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
export function ImageCropper({ image, onCropped, onCancel }: { image: File, onCropped: (blob: Blob) => void, onCancel: () => void }) {
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||||
<form>
|
setOpen(isOpen)
|
||||||
|
if (!isOpen && onCancel) onCancel()
|
||||||
|
}}>
|
||||||
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline">Edit</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!image}
|
||||||
|
className={!image ? "opacity-50 cursor-not-allowed" : ""}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
|
|
||||||
|
{image && (
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<h2 className="text-lg font-medium mb-2">裁剪图片</h2>
|
||||||
|
<div className="w-full h-[360px] bg-gray-100 flex items-center justify-center overflow-auto">
|
||||||
|
{imageSrc ? (
|
||||||
|
<ReactCrop
|
||||||
|
crop={crop as Crop}
|
||||||
|
onChange={(c) => setCrop(c)}
|
||||||
|
// 保持正方形,可按需移除
|
||||||
|
aspect={1}
|
||||||
|
>
|
||||||
|
{/* 必须用原生 img 元素 */}
|
||||||
|
<Image
|
||||||
|
src={imageSrc}
|
||||||
|
alt="source"
|
||||||
|
onLoad={onImageLoad}
|
||||||
|
width={640}
|
||||||
|
height={640}
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '60vh', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</ReactCrop>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">没有待裁剪图片</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mt-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>预览</Label>
|
||||||
|
<div className="w-32 h-32 border bg-white overflow-hidden">
|
||||||
|
{/* 临时预览:把裁剪结果画到 canvas 并显示 */}
|
||||||
|
<PreviewCanvas crop={crop} imgRef={imgRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>提示</Label>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
建议上传正方形头像;裁剪将导出 PNG 文件。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button variant="outline" type="button" onClick={handleClose}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const blob = await getCroppedBlob()
|
||||||
|
if (blob) {
|
||||||
|
onCropped(blob)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 简单的预览 canvas 组件(显示当前裁剪区域)
|
||||||
|
function PreviewCanvas({ crop, imgRef }: { crop: Partial<Crop>, imgRef: React.RefObject<HTMLImageElement | null> }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const img = imgRef.current
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!img || !canvas || !crop) return
|
||||||
|
|
||||||
|
const unitIsPercent = (crop.unit ?? '%') === '%'
|
||||||
|
const renderWidth = img.width
|
||||||
|
const renderHeight = img.height
|
||||||
|
|
||||||
|
const sxRender = unitIsPercent ? ((crop.x ?? 0) / 100) * renderWidth : (crop.x ?? 0)
|
||||||
|
const syRender = unitIsPercent ? ((crop.y ?? 0) / 100) * renderHeight : (crop.y ?? 0)
|
||||||
|
const swRender = unitIsPercent ? ((crop.width ?? 0) / 100) * renderWidth : (crop.width ?? 0)
|
||||||
|
const shRender = unitIsPercent ? ((crop.height ?? 0) / 100) * renderHeight : (crop.height ?? 0)
|
||||||
|
|
||||||
|
const scaleX = img.naturalWidth / renderWidth
|
||||||
|
const scaleY = img.naturalHeight / renderHeight
|
||||||
|
const sx = Math.round(sxRender * scaleX)
|
||||||
|
const sy = Math.round(syRender * scaleY)
|
||||||
|
const sw = Math.max(1, Math.round(swRender * scaleX))
|
||||||
|
const sh = Math.max(1, Math.round(shRender * scaleY))
|
||||||
|
|
||||||
|
// 画到 preview canvas(缩放以适应 128x128)
|
||||||
|
const outW = 128
|
||||||
|
const outH = Math.max(1, Math.round((sh / sw) * outW))
|
||||||
|
canvas.width = outW
|
||||||
|
canvas.height = outH
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.clearRect(0, 0, outW, outH)
|
||||||
|
// drawImage 使用 natural pixel 区域
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outW, outH)
|
||||||
|
}, [crop, imgRef])
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
|
||||||
|
}
|
@ -1,291 +0,0 @@
|
|||||||
"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}
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,807 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,11 +1,13 @@
|
|||||||
import type { User } from "@/models/user";
|
import type { User } from "@/models/user";
|
||||||
|
import { IconType } from "@/types/icon";
|
||||||
import { isAdmin, isEditor } from "@/utils/common/permission";
|
import { isAdmin, isEditor } from "@/utils/common/permission";
|
||||||
import { Folder, Gauge, MessageCircle, Newspaper, Palette, Settings, ShieldCheck, UserPen, Users } from "lucide-react";
|
import { Folder, Gauge, MessageCircle, Newspaper, Palette, Settings, ShieldCheck, UserPen, Users } from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
export interface SidebarItem {
|
export interface SidebarItem {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon: React.ComponentType<any>;
|
icon: IconType;
|
||||||
permission: ({ user }: { user: User }) => boolean;
|
permission: ({ user }: { user: User }) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,11 +9,10 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import type { LucideProps } from "lucide-react";
|
|
||||||
import { ComponentType, SVGProps } from "react"
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { User } from "@/models/user";
|
import { User } from "@/models/user";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { IconType } from "@/types/icon";
|
||||||
|
|
||||||
export function NavMain({
|
export function NavMain({
|
||||||
items,
|
items,
|
||||||
@ -21,7 +20,7 @@ export function NavMain({
|
|||||||
items: {
|
items: {
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
icon?: ComponentType<SVGProps<SVGSVGElement> & LucideProps>;
|
icon?: IconType;
|
||||||
permission: ({ user }: { user: User }) => boolean
|
permission: ({ user }: { user: User }) => boolean
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,35 +1,18 @@
|
|||||||
"use client"
|
"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 {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { ComponentType, SVGProps } from "react"
|
|
||||||
import { LucideProps } from "lucide-react"
|
|
||||||
import { User } from "@/models/user"
|
import { User } from "@/models/user"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { IconType } from "@/types/icon"
|
||||||
|
|
||||||
export function NavUserCenter({
|
export function NavUserCenter({
|
||||||
items,
|
items,
|
||||||
@ -37,11 +20,10 @@ export function NavUserCenter({
|
|||||||
items: {
|
items: {
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
icon?: ComponentType<SVGProps<SVGSVGElement> & LucideProps>;
|
icon?: IconType;
|
||||||
permission: ({ user }: { user: User }) => boolean
|
permission: ({ user }: { user: User }) => boolean
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar()
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const pathname = usePathname() ?? "/"
|
const pathname = usePathname() ?? "/"
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ import { useAuth } from "@/contexts/auth-context"
|
|||||||
import { userLogout } from "@/api/user"
|
import { userLogout } from "@/api/user"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export function NavUser({ }: {}) {
|
export function NavUser() {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
@ -1,102 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -14,6 +14,7 @@ import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
||||||
interface UploadConstraints {
|
interface UploadConstraints {
|
||||||
allowedTypes: string[];
|
allowedTypes: string[];
|
||||||
maxSize: number;
|
maxSize: number;
|
||||||
@ -25,24 +26,28 @@ interface PictureInputChangeEvent {
|
|||||||
|
|
||||||
export function UserProfilePage() {
|
export function UserProfilePage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
if (!user) return null
|
|
||||||
|
|
||||||
const [nickname, setNickname] = useState(user.nickname || '')
|
|
||||||
const [username, setUsername] = useState(user.username || '')
|
const [nickname, setNickname] = useState(user?.nickname || '')
|
||||||
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '')
|
const [username, setUsername] = useState(user?.username || '')
|
||||||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||||
const [gender, setGender] = useState(user.gender || '')
|
const [avatarFileUrl, setAvatarFileUrl] = useState<string | null>(null) // 这部分交由useEffect控制,监听 avatarFile 变化
|
||||||
|
const [gender, setGender] = useState(user?.gender || '')
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// if (!avatarFile) return
|
if (!user) return;
|
||||||
// uploadFile({ file: avatarFile! }).then(res => {
|
if (!avatarFile) {
|
||||||
// setAvatarUrl(getFileUri(res.data.id))
|
setAvatarFileUrl(getGravatarFromUser({ user }));
|
||||||
// toast.success('Avatar uploaded successfully')
|
return;
|
||||||
// }).catch(err => {
|
}
|
||||||
// console.log(err)
|
const url = URL.createObjectURL(avatarFile);
|
||||||
// toast.error(`Error: ${err?.response?.data?.message || err.message || 'Failed to upload avatar'}`)
|
setAvatarFileUrl(url);
|
||||||
// })
|
return () => {
|
||||||
}, [avatarFile])
|
URL.revokeObjectURL(url);
|
||||||
|
setAvatarFileUrl(getGravatarFromUser({ user }));
|
||||||
|
};
|
||||||
|
}, [avatarFile, user]);
|
||||||
|
|
||||||
const handlePictureSelected = (e: PictureInputChangeEvent): void => {
|
const handlePictureSelected = (e: PictureInputChangeEvent): void => {
|
||||||
const file: File | null = e.target.files?.[0] ?? null;
|
const file: File | null = e.target.files?.[0] ?? null;
|
||||||
@ -68,26 +73,62 @@ export function UserProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (nickname.trim() === '' || username.trim() === '') {
|
if (!user) return;
|
||||||
|
if (
|
||||||
|
nickname.trim() === '' ||
|
||||||
|
username.trim() === ''
|
||||||
|
) {
|
||||||
toast.error('Nickname and Username cannot be empty')
|
toast.error('Nickname and Username cannot be empty')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ((username.length < 3 || username.length > 20) || (nickname.length < 1 || nickname.length > 20)) {
|
|
||||||
|
if (
|
||||||
|
(username.length < 3 || username.length > 20) ||
|
||||||
|
(nickname.length < 1 || nickname.length > 20)
|
||||||
|
) {
|
||||||
toast.error('Nickname and Username must be between 3 and 20 characters')
|
toast.error('Nickname and Username must be between 3 and 20 characters')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (username === user.username && nickname === user.nickname && avatarUrl === user.avatarUrl && gender === user.gender) {
|
|
||||||
|
if (
|
||||||
|
username === user.username &&
|
||||||
|
nickname === user.nickname &&
|
||||||
|
gender === user.gender &&
|
||||||
|
avatarFile === null
|
||||||
|
) {
|
||||||
toast.warning('No changes made')
|
toast.warning('No changes made')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updateUser({ nickname, username, avatarUrl, gender, id: user.id }).then(res => {
|
|
||||||
toast.success('Profile updated successfully')
|
let avatarUrl = user.avatarUrl;
|
||||||
window.location.reload()
|
(async () => {
|
||||||
}).catch(err => {
|
if (avatarFile) {
|
||||||
console.log(err)
|
try {
|
||||||
toast.error(`Error: ${err?.response.data?.message || err.message || 'Failed to update profile'}`)
|
const resp = await uploadFile({ file: avatarFile });
|
||||||
})
|
avatarUrl = getFileUri(resp.data.id);
|
||||||
|
console.log('Uploaded avatar, got URL:', avatarUrl);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error(`Failed to upload avatar ${error}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateUser({ nickname, username, avatarUrl, gender, id: user.id });
|
||||||
|
toast.success('Profile updated successfully');
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error(`Failed to update profile ${error}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCropped = (blob: Blob) => {
|
||||||
|
const file = new File([blob], 'avatar.png', { type: blob.type });
|
||||||
|
setAvatarFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -98,26 +139,19 @@ export function UserProfilePage() {
|
|||||||
<div className="grid w-full max-w-sm items-center gap-3">
|
<div className="grid w-full max-w-sm items-center gap-3">
|
||||||
<Label htmlFor="picture">Picture</Label>
|
<Label htmlFor="picture">Picture</Label>
|
||||||
<Avatar className="h-40 w-40 rounded-xl border-2">
|
<Avatar className="h-40 w-40 rounded-xl border-2">
|
||||||
{!avatarFile && <AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />}
|
{avatarFileUrl ?
|
||||||
{avatarFile && <AvatarImage src={URL.createObjectURL(avatarFile)} alt={user.username} />}
|
<AvatarImage src={avatarFileUrl} alt={nickname || username} /> :
|
||||||
|
<AvatarImage src={getGravatarFromUser({ user })} alt={nickname || username} />}
|
||||||
<AvatarFallback>{getFallbackAvatarFromUsername(nickname || username)}</AvatarFallback>
|
<AvatarFallback>{getFallbackAvatarFromUsername(nickname || username)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<div className="flex gap-3"><Input
|
<div className="flex gap-3"><Input
|
||||||
id="picture"
|
id="picture"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif,image/*"
|
accept="image/png,image/jpeg,image/webp,image/gif,image/*"
|
||||||
onChange={handlePictureSelected}
|
onChange={handlePictureSelected}
|
||||||
/>
|
/>
|
||||||
<ImageCropper />
|
<ImageCropper image={avatarFile} onCropped={handleCropped} />
|
||||||
</div>
|
</div>
|
||||||
<Input
|
|
||||||
id="picture-url"
|
|
||||||
type="url"
|
|
||||||
value={avatarUrl}
|
|
||||||
onChange={(e) => setAvatarUrl(e.target.value)}
|
|
||||||
placeholder="若要用外链图像,请直接填写,不支持裁剪"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="nickname">Nickname</Label>
|
<Label htmlFor="nickname">Nickname</Label>
|
||||||
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
|
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
|
||||||
<Label htmlFor="username">Username</Label>
|
<Label htmlFor="username">Username</Label>
|
||||||
@ -130,6 +164,6 @@ export function UserProfilePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PictureEditor({}){
|
export function PictureEditor({ }) {
|
||||||
|
|
||||||
}
|
}
|
@ -10,7 +10,7 @@ import {
|
|||||||
} from "@/components/ui/input-otp"
|
} from "@/components/ui/input-otp"
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const VERIFY_CODE_COOL_DOWN = 60; // seconds
|
// const VERIFY_CODE_COOL_DOWN = 60; // seconds
|
||||||
|
|
||||||
export function UserSecurityPage() {
|
export function UserSecurityPage() {
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
|
4
web/src/types/icon.ts
Normal file
4
web/src/types/icon.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { LucideProps } from "lucide-react";
|
||||||
|
import { ComponentType, SVGProps } from "react";
|
||||||
|
|
||||||
|
export type IconType = ComponentType<SVGProps<SVGSVGElement> & LucideProps>
|
Reference in New Issue
Block a user