Compare commits
2 Commits
4169e265ab
...
716e5cffb4
Author | SHA1 | Date |
---|---|---|
|
716e5cffb4 | 6 months ago |
|
f16bb5862e | 6 months ago |
@ -0,0 +1,137 @@ |
||||
import DataTable from "@/components/(dashboard)/common/DataTable/DataTable" |
||||
import { Button } from "@/components/(dashboard)/ui/button" |
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar" |
||||
import { Badge } from "@/components/ui/badge" |
||||
import { routes } from "@/lib/routes" |
||||
import { ColumnDef } from "@tanstack/react-table" |
||||
import { ArrowUpDown } from "lucide-react" |
||||
import Link from "next/link" |
||||
|
||||
const UserTable :React.FC<{ |
||||
mutate : () => void |
||||
userData : Array<any> |
||||
isLoading : boolean |
||||
}> = ({ |
||||
mutate ,
|
||||
userData ,
|
||||
isLoading |
||||
}) => { |
||||
|
||||
const columns: ColumnDef<any>[] = [ |
||||
{ |
||||
accessorKey: "sn", |
||||
header: "SN", |
||||
cell: ({ row }) => ( |
||||
<div className="capitalize">{row.index + 1}</div> |
||||
), |
||||
}, |
||||
{ |
||||
id: 'name', |
||||
accessorFn: (row: any) => row.original?.firstName, |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
className="!px-0" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
> |
||||
Name |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => ( |
||||
<div className="capitalize flex gap-2"> |
||||
<Avatar className="h-8 w-8"> |
||||
<AvatarImage
|
||||
src={row.original?.profilePicture ?? 'no image path'}
|
||||
alt={row.original?.firstName}
|
||||
/> |
||||
</Avatar> |
||||
<p>{row?.original?.firstName}</p> |
||||
</div> |
||||
), |
||||
}, |
||||
{ |
||||
id: 'email', |
||||
accessorFn: (row: any) => row.original?.email, |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
className="!px-0" |
||||
> |
||||
Email |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => ( |
||||
<div>{row.original?.email}</div> |
||||
), |
||||
}, |
||||
{ |
||||
id: 'dateOfBirth', |
||||
accessorFn: (row: any) => row.original?.dateOfBirth, |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
className="!px-0" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
> |
||||
DateOfBirth |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => ( |
||||
<div className="capitalize">{row.original?.dateOfBirth}</div> |
||||
), |
||||
}, |
||||
{ |
||||
id: 'isActivated', |
||||
accessorFn: (row: any) => row.original?.isActivated, |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
className="!px-0" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
> |
||||
IsActive |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => ( |
||||
<div>{row.original?.isActivated ? <Badge variant={'success'}>Active</Badge> : <Badge variant={'destructive'}>Deactive</Badge>}</div> |
||||
), |
||||
}, |
||||
{ |
||||
id: 'last_online_at', |
||||
accessorFn: (row: any) => row.original?.lastOnline, |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
className="!px-0" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
> |
||||
lastOnline |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => ( |
||||
<div> |
||||
{row.original?.lastOnline ?? '-'}
|
||||
</div> |
||||
), |
||||
}, |
||||
] |
||||
return( |
||||
<> |
||||
<DataTable
|
||||
data={userData} |
||||
columns={columns} |
||||
mutate={mutate} |
||||
searchKey="username" |
||||
isLoading={isLoading} |
||||
/> |
||||
</> |
||||
) |
||||
} |
||||
|
||||
export default UserTable |
@ -0,0 +1,56 @@ |
||||
'use client' |
||||
import UserTabContent from "@/app/user/profile/_partials/UserTabContent" |
||||
import BreadCrumbNav from "@/components/(dashboard)/common/BreadCumbNav/BreadCrumbNav" |
||||
import DataTable from "@/components/(dashboard)/common/DataTable/DataTable" |
||||
import ContentContainer from "@/components/(dashboard)/elements/ContentContainer" |
||||
import { PageHeading } from "@/components/(dashboard)/ui/title" |
||||
import CommonContainer from "@/components/elements/CommonContainer" |
||||
import AppContextProvider from "@/helpers/context/AppContextProvider" |
||||
import { defaultFetcher } from "@/helpers/fetch.helper" |
||||
import { routes } from "@/lib/routes" |
||||
import { APP_BASE_URL } from "@/utils/constants" |
||||
import AdminView from "@/views/AdminView" |
||||
import useSWR from "swr" |
||||
import UserTable from "./_partials/UserTable" |
||||
|
||||
const UsersIndexPage = () => { |
||||
const UserListURL = `${APP_BASE_URL}/api/admin/stats/userDetail` |
||||
const { data : UsersList , mutate , isLoading} = useSWR(UserListURL , defaultFetcher); |
||||
|
||||
|
||||
console.log(UsersList) |
||||
|
||||
return( |
||||
<> |
||||
<AppContextProvider> |
||||
<AdminView> |
||||
<CommonContainer> |
||||
<PageHeading>Users</PageHeading> |
||||
<BreadCrumbNav breadCrumbItems={[ |
||||
{ |
||||
title : 'Dashboard', |
||||
href : routes.DASHBOARD_ROUTE |
||||
}, |
||||
{ |
||||
title : 'Users', |
||||
href : routes.USER_INDEX_PAGE |
||||
}, |
||||
]}/> |
||||
<ContentContainer> |
||||
<div> |
||||
<UserTable
|
||||
userData={UsersList?.users} |
||||
mutate={mutate} |
||||
isLoading={isLoading} |
||||
/> |
||||
</div> |
||||
</ContentContainer> |
||||
</CommonContainer> |
||||
</AdminView> |
||||
</AppContextProvider> |
||||
</> |
||||
) |
||||
} |
||||
|
||||
export default UsersIndexPage |
||||
|
@ -0,0 +1,179 @@ |
||||
import { useState, useEffect } from 'react'; |
||||
import { Card, CardContent } from "@/components/ui/card"; |
||||
import { Button } from "@/components/ui/button"; |
||||
import { Upload, X, AlertCircle } from 'lucide-react'; |
||||
import { Alert, AlertDescription } from "@/components/ui/alert"; |
||||
|
||||
interface BannerUploadProps { |
||||
onImageChange?: (file: File | null) => void; |
||||
defaultImage?: string; |
||||
maxSizeMB?: number; |
||||
} |
||||
|
||||
export default function BannerImageUpload({
|
||||
onImageChange,
|
||||
defaultImage, |
||||
maxSizeMB = 5
|
||||
}: BannerUploadProps) { |
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(defaultImage || null); |
||||
const [error, setError] = useState<string | null>(null); |
||||
const [isDragging, setIsDragging] = useState(false); |
||||
|
||||
const acceptedTypes = ['image/jpeg', 'image/png', 'image/webp']; |
||||
const maxSize = maxSizeMB * 1024 * 1024; // Convert MB to bytes
|
||||
|
||||
const validateFile = (file: File): string | null => { |
||||
if (!acceptedTypes.includes(file.type)) { |
||||
return 'Please upload a valid image file (JPG, PNG, or WebP)'; |
||||
} |
||||
if (file.size > maxSize) { |
||||
return `File size should be less than ${maxSizeMB}MB`; |
||||
} |
||||
return null; |
||||
}; |
||||
|
||||
const handleFile = (file: File) => { |
||||
const validationError = validateFile(file); |
||||
if (validationError) { |
||||
setError(validationError); |
||||
return; |
||||
} |
||||
|
||||
// Clear any existing error
|
||||
setError(null); |
||||
|
||||
// Create preview URL
|
||||
const objectUrl = URL.createObjectURL(file); |
||||
setPreviewUrl(objectUrl); |
||||
|
||||
// Notify parent component
|
||||
if (onImageChange) { |
||||
onImageChange(file); |
||||
} |
||||
}; |
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { |
||||
const file = e.target.files?.[0]; |
||||
if (file) { |
||||
handleFile(file); |
||||
} |
||||
}; |
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { |
||||
e.preventDefault(); |
||||
setIsDragging(false); |
||||
|
||||
const file = e.dataTransfer.files?.[0]; |
||||
if (file) { |
||||
handleFile(file); |
||||
} |
||||
}; |
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { |
||||
e.preventDefault(); |
||||
setIsDragging(true); |
||||
}; |
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => { |
||||
e.preventDefault(); |
||||
setIsDragging(false); |
||||
}; |
||||
|
||||
const removeBanner = () => { |
||||
if (previewUrl) { |
||||
URL.revokeObjectURL(previewUrl); |
||||
} |
||||
setPreviewUrl(null); |
||||
setError(null); |
||||
if (onImageChange) { |
||||
onImageChange(null); |
||||
} |
||||
}; |
||||
|
||||
// Cleanup preview URL when component unmounts
|
||||
useEffect(() => { |
||||
return () => { |
||||
if (previewUrl && previewUrl !== defaultImage) { |
||||
URL.revokeObjectURL(previewUrl); |
||||
} |
||||
}; |
||||
}, [previewUrl, defaultImage]); |
||||
|
||||
return ( |
||||
<div className="space-y-4 col-span-2"> |
||||
<label>Upload Course Banner</label> |
||||
<div |
||||
className={`relative w-full h-64 rounded-lg overflow-hidden border-2 border-dashed transition-colors
|
||||
${isDragging ? 'border-purple-500 bg-purple-50' : 'border-gray-300 bg-gray-50'} |
||||
${error ? 'border-red-300' : ''}`}
|
||||
onDrop={handleDrop} |
||||
onDragOver={handleDragOver} |
||||
onDragLeave={handleDragLeave} |
||||
> |
||||
{previewUrl ? ( |
||||
<> |
||||
<img |
||||
src={previewUrl} |
||||
alt="Banner preview" |
||||
className="w-full h-full object-cover" |
||||
/> |
||||
<div className="absolute inset-0 bg-black bg-opacity-40 opacity-0 hover:opacity-100 transition-opacity"> |
||||
<div className="absolute inset-0 flex items-center justify-center space-x-4"> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
className="bg-white hover:bg-gray-100" |
||||
onClick={() => document.getElementById('banner-upload')?.click()} |
||||
> |
||||
<Upload className="h-5 w-5 mr-2" /> |
||||
Change Banner |
||||
</Button> |
||||
<Button |
||||
type="button" |
||||
variant="destructive" |
||||
onClick={removeBanner} |
||||
> |
||||
<X className="h-5 w-5 mr-2" /> |
||||
Remove Banner |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</> |
||||
) : ( |
||||
<div className="absolute inset-0 flex flex-col items-center justify-center"> |
||||
<Upload className="h-12 w-12 text-gray-400 mb-4" /> |
||||
<div className="text-center"> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
className="mb-2" |
||||
onClick={() => document.getElementById('banner-upload')?.click()} |
||||
> |
||||
Choose File |
||||
</Button> |
||||
<p className="text-sm text-gray-500">or drag and drop your image here</p> |
||||
<p className="text-xs text-gray-400 mt-2"> |
||||
Supports: JPG, PNG, WebP (max {maxSizeMB}MB) |
||||
</p> |
||||
</div> |
||||
</div> |
||||
)} |
||||
|
||||
<input |
||||
id="banner-upload" |
||||
type="file" |
||||
className="hidden" |
||||
accept={acceptedTypes.join(',')} |
||||
onChange={handleFileSelect} |
||||
/> |
||||
</div> |
||||
|
||||
{error && ( |
||||
<Alert variant="destructive"> |
||||
<AlertCircle className="h-4 w-4" /> |
||||
<AlertDescription>{error}</AlertDescription> |
||||
</Alert> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,8 @@ |
||||
export const CourseCategoryOptionHelper = (data: Record<string, string>[]) : any => { |
||||
return data?.length > 0
|
||||
? data.map((item) => ({ |
||||
value: item?.id?.toString() ? item?.id?.toString() : item?.idx, |
||||
label: item?.title ? item.title : item?.name, |
||||
})) |
||||
: []; |
||||
} |
@ -0,0 +1,74 @@ |
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" |
||||
import { defaultFetcher } from "@/helpers/fetch.helper" |
||||
import { APP_BASE_URL } from "@/utils/constants" |
||||
import Image from "next/image" |
||||
import useSWR from "swr" |
||||
|
||||
const EnrolledCourseTabContent : React.FC = () => { |
||||
const { data } = useSWR(APP_BASE_URL + '/' , defaultFetcher); |
||||
|
||||
return ( |
||||
<> |
||||
<Card className='border border-purple-700/20 shadow shadow-purple-700/40'> |
||||
<CardHeader> |
||||
<CardTitle className="text-2xl text-purple-700">Enrolled Courses List </CardTitle> |
||||
</CardHeader> |
||||
<CardContent> |
||||
{ |
||||
data?.length && data.map((course : Record<string,any> , index : number) => { |
||||
return( |
||||
<> |
||||
<Card |
||||
key={course.id} |
||||
className="hover:shadow-lg transition-shadow" |
||||
> |
||||
<CardContent className="p-6 flex gap-4"> |
||||
<div > |
||||
<Image src="https://placehold.jp/400x250.png"alt="placeholder" width={400} height={250} className="w-[400] h-auto rounded-2xl"/> |
||||
</div> |
||||
<div> |
||||
<div className="flex items-start justify-between"> |
||||
<div className="space-y-2"> |
||||
<h3 className="text-xl font-semibold text-purple-700"> |
||||
{course.title} |
||||
</h3> |
||||
<p className="text-gray-600">{course.description}</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-sm text-gray-500"> |
||||
<span className="flex items-center"> |
||||
<span className="font-medium">Published:</span> |
||||
<span className="ml-1">{course.publishedDate}</span> |
||||
</span> |
||||
<span className="flex items-center"> |
||||
<span className="font-medium">Enrolled:</span> |
||||
<span className="ml-1"> |
||||
{course.enrolledStudent} students |
||||
</span> |
||||
</span> |
||||
<span className="flex items-center"> |
||||
<span className="font-medium">Active Discussions:</span> |
||||
<span className="ml-1"> |
||||
{course.activeDiscussionCount} |
||||
</span> |
||||
</span> |
||||
<span className="flex items-center"> |
||||
<span className="font-medium">Published by:</span> |
||||
<span className="ml-1">{course.publishedBy}</span> |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</CardContent> |
||||
</Card> |
||||
</> |
||||
) |
||||
}) |
||||
} |
||||
</CardContent> |
||||
</Card> |
||||
</> |
||||
) |
||||
} |
||||
|
||||
export default EnrolledCourseTabContent |
@ -0,0 +1,74 @@ |
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" |
||||
import { defaultFetcher } from "@/helpers/fetch.helper" |
||||
import { APP_BASE_URL } from "@/utils/constants" |
||||
import Image from "next/image" |
||||
import useSWR from "swr" |
||||
|
||||
const MyCoursesListTabContent = () => { |
||||
const { data } = useSWR(APP_BASE_URL + '/' , defaultFetcher); |
||||
|
||||
return ( |
||||
<> |
||||
<Card className='border border-purple-700/20 shadow shadow-purple-700/40'> |
||||
<CardHeader> |
||||
<CardTitle className="text-2xl text-purple-700">My Courses</CardTitle> |
||||
</CardHeader> |
||||
<CardContent> |
||||
{ |
||||
data?.length && data.map((course : Record<string,any> , index : number) => { |
||||
return( |
||||
<> |
||||
<Card |
||||
key={course.id} |
||||
className="hover:shadow-lg transition-shadow" |
||||
> |
||||
<CardContent className="p-6 flex gap-4"> |
||||
<div > |
||||
<Image src="https://placehold.jp/400x250.png"alt="placeholder" width={400} height={250} className="w-[400] h-auto rounded-2xl"/> |
||||
</div> |
||||
<div> |
||||
<div className="flex items-start justify-between"> |
||||
<div className="space-y-2"> |
||||
<h3 className="text-xl font-semibold text-purple-700"> |
||||
{course.title} |
||||
</h3> |
||||
<p className="text-gray-600">{course.description}</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-sm text-gray-500"> |
||||
<span className="flex items-center"> |
||||
<span className="font-medium">Published:</span> |
||||
<span className="ml-1">{course.publishedDate}</span> |
||||
</span> |
||||
<span className="flex items-center"> |
||||
<span className="font-medium">Enrolled:</span> |
||||
<span className="ml-1"> |
||||
{course.enrolledStudent} students |
||||
</span> |
||||
</span> |
||||
<span className="flex items-center"> |
||||
<span className="font-medium">Active Discussions:</span> |
||||
<span className="ml-1"> |
||||
{course.activeDiscussionCount} |
||||
</span> |
||||
</span> |
||||
<span className="flex items-center"> |
||||
<span className="font-medium">Published by:</span> |
||||
<span className="ml-1">{course.publishedBy}</span> |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</CardContent> |
||||
</Card> |
||||
</> |
||||
) |
||||
}) |
||||
} |
||||
</CardContent> |
||||
</Card> |
||||
</> |
||||
) |
||||
} |
||||
|
||||
export default MyCoursesListTabContent |
After Width: | Height: | Size: 14 KiB |
@ -0,0 +1,59 @@ |
||||
import * as React from "react" |
||||
import { cva, type VariantProps } from "class-variance-authority" |
||||
|
||||
import { cn } from "@/lib/utils" |
||||
|
||||
const alertVariants = cva( |
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", |
||||
{ |
||||
variants: { |
||||
variant: { |
||||
default: "bg-background text-foreground", |
||||
destructive: |
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", |
||||
}, |
||||
}, |
||||
defaultVariants: { |
||||
variant: "default", |
||||
}, |
||||
} |
||||
) |
||||
|
||||
const Alert = React.forwardRef< |
||||
HTMLDivElement, |
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> |
||||
>(({ className, variant, ...props }, ref) => ( |
||||
<div |
||||
ref={ref} |
||||
role="alert" |
||||
className={cn(alertVariants({ variant }), className)} |
||||
{...props} |
||||
/> |
||||
)) |
||||
Alert.displayName = "Alert" |
||||
|
||||
const AlertTitle = React.forwardRef< |
||||
HTMLParagraphElement, |
||||
React.HTMLAttributes<HTMLHeadingElement> |
||||
>(({ className, ...props }, ref) => ( |
||||
<h5 |
||||
ref={ref} |
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)} |
||||
{...props} |
||||
/> |
||||
)) |
||||
AlertTitle.displayName = "AlertTitle" |
||||
|
||||
const AlertDescription = React.forwardRef< |
||||
HTMLParagraphElement, |
||||
React.HTMLAttributes<HTMLParagraphElement> |
||||
>(({ className, ...props }, ref) => ( |
||||
<div |
||||
ref={ref} |
||||
className={cn("text-sm [&_p]:leading-relaxed", className)} |
||||
{...props} |
||||
/> |
||||
)) |
||||
AlertDescription.displayName = "AlertDescription" |
||||
|
||||
export { Alert, AlertTitle, AlertDescription } |
Loading…
Reference in new issue