commit
3f777b170d
@ -1,9 +1,113 @@ |
|||||||
const CategoryTable = () => { |
import DataTable from "@/components/(dashboard)/common/DataTable/DataTable" |
||||||
return( |
import { Button } from "@/components/(dashboard)/ui/button" |
||||||
<> |
import { Badge } from "@/components/ui/badge" |
||||||
|
import { routes } from "@/lib/routes" |
||||||
|
import { ColumnDef } from "@tanstack/react-table" |
||||||
|
import { ArrowUpDown } from "lucide-react" |
||||||
|
|
||||||
</> |
const CategoryTable: React.FC<{ |
||||||
) |
mutate: () => void |
||||||
|
Data: Array<any> |
||||||
|
isLoading: boolean |
||||||
|
}> = ({ |
||||||
|
mutate, |
||||||
|
Data, |
||||||
|
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?.name, |
||||||
|
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"> |
||||||
|
<p>{row.original?.name}</p> |
||||||
|
</div> |
||||||
|
), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'description', |
||||||
|
accessorFn: (row: any) => row.original?.description, |
||||||
|
header: ({ column }) => ( |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||||
|
className="!px-0" |
||||||
|
> |
||||||
|
Description |
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||||
|
</Button> |
||||||
|
), |
||||||
|
cell: ({ row }) => ( |
||||||
|
<div className="max-w-[300px] truncate">{row.original?.description ?? '-'}</div> |
||||||
|
), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'creationDate', |
||||||
|
accessorFn: (row: any) => row.original?.creationDate, |
||||||
|
header: ({ column }) => ( |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
className="!px-0" |
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||||
|
> |
||||||
|
Created Date |
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||||
|
</Button> |
||||||
|
), |
||||||
|
cell: ({ row }) => ( |
||||||
|
<div className="whitespace-nowrap"> |
||||||
|
{row.original?.creationDate ? new Date(row.original.creationDate).toLocaleDateString() : '-'} |
||||||
|
</div> |
||||||
|
), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'isActive', |
||||||
|
accessorFn: (row: any) => row.original?.isActive, |
||||||
|
header: ({ column }) => ( |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
className="!px-0" |
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||||
|
> |
||||||
|
Status |
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||||
|
</Button> |
||||||
|
), |
||||||
|
cell: ({ row }) => ( |
||||||
|
<div>{row.original?.isActive ? <Badge variant={'success'}>Active</Badge> : <Badge variant={'destructive'}>Deactive</Badge>}</div> |
||||||
|
), |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
return( |
||||||
|
<> |
||||||
|
<DataTable |
||||||
|
data={Data} |
||||||
|
columns={columns} |
||||||
|
mutate={mutate} |
||||||
|
searchKey="name" |
||||||
|
isLoading={isLoading} |
||||||
|
/> |
||||||
|
</> |
||||||
|
) |
||||||
} |
} |
||||||
|
|
||||||
export default Categ |
export default CategoryTable |
||||||
|
@ -0,0 +1,300 @@ |
|||||||
|
import React, { useState, useRef } from 'react'; |
||||||
|
import useSWRInfinite from 'swr/infinite'; |
||||||
|
import { Card } from "@/components/ui/card"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; |
||||||
|
import { Textarea } from "@/components/ui/textarea"; |
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"; |
||||||
|
import { MessageCircle, Reply, X } from "lucide-react"; |
||||||
|
import { APP_BASE_URL } from '@/utils/constants'; |
||||||
|
import { getEduConnectAccessToken } from '@/helpers/token.helper'; |
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Reply { |
||||||
|
id: number; |
||||||
|
user: string; |
||||||
|
avatar: string; |
||||||
|
content: string; |
||||||
|
timestamp: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface Discussion { |
||||||
|
id: number; |
||||||
|
user: string; |
||||||
|
avatar: string; |
||||||
|
content: string; |
||||||
|
likes: number; |
||||||
|
replies: Reply[]; |
||||||
|
timestamp: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface PageData { |
||||||
|
discussions: Discussion[]; |
||||||
|
hasMore: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
interface DiscussionSectionProps { |
||||||
|
courseUuid: string; |
||||||
|
} |
||||||
|
|
||||||
|
// Key generator for SWR
|
||||||
|
const getKey = (courseUuid: string) => (pageIndex: number, previousPageData: PageData | null) => { |
||||||
|
if (previousPageData && !previousPageData.discussions.length) return null; |
||||||
|
return `${APP_BASE_URL}/api/chat/get?page=${pageIndex}`; |
||||||
|
}; |
||||||
|
|
||||||
|
// Fetcher function
|
||||||
|
const fetcher = async (url: string, courseUuid: string): Promise<PageData> => { |
||||||
|
const formData = new FormData(); |
||||||
|
formData.append('course_uuid', courseUuid); |
||||||
|
|
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'POST', |
||||||
|
body: formData ,
|
||||||
|
headers : { |
||||||
|
Authorization : `Bearer ${getEduConnectAccessToken()}` |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error('Failed to fetch discussions'); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
return { |
||||||
|
discussions: data.discussions.map((discussion: any) => ({ |
||||||
|
...discussion, |
||||||
|
timestamp: new Date(discussion.timestamp).toLocaleString() |
||||||
|
})), |
||||||
|
hasMore: data.hasMore |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
const EnhancedDiscussionSection: React.FC<DiscussionSectionProps> = ({ courseUuid }) => { |
||||||
|
const [comment, setComment] = useState(''); |
||||||
|
const [replyingTo, setReplyingTo] = useState<{ commentId: number; userName: string } | null>(null); |
||||||
|
const loadingRef = useRef<HTMLDivElement>(null); |
||||||
|
const containerRef = useRef<HTMLDivElement>(null); |
||||||
|
|
||||||
|
const { |
||||||
|
data, |
||||||
|
error, |
||||||
|
size, |
||||||
|
setSize, |
||||||
|
isValidating, |
||||||
|
mutate |
||||||
|
} = useSWRInfinite( |
||||||
|
getKey(courseUuid), |
||||||
|
(url) => fetcher(url, courseUuid), |
||||||
|
{ |
||||||
|
revalidateFirstPage: false, |
||||||
|
revalidateOnFocus: false, |
||||||
|
persistSize: true |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
const allDiscussions = data ? data.flatMap(page => page.discussions) : []; |
||||||
|
const hasMore = data ? data[data.length - 1]?.hasMore : true; |
||||||
|
const isLoadingMore = !data && !error || (size > 0 && data && typeof data[size - 1] === "undefined"); |
||||||
|
|
||||||
|
// Intersection Observer for infinite loading
|
||||||
|
React.useEffect(() => { |
||||||
|
const observer = new IntersectionObserver( |
||||||
|
(entries) => { |
||||||
|
if (entries[0].isIntersecting && hasMore && !isValidating) { |
||||||
|
setSize(size + 1); |
||||||
|
} |
||||||
|
}, |
||||||
|
{ threshold: 0.1 } |
||||||
|
); |
||||||
|
|
||||||
|
if (loadingRef.current) { |
||||||
|
observer.observe(loadingRef.current); |
||||||
|
} |
||||||
|
|
||||||
|
return () => observer.disconnect(); |
||||||
|
}, [hasMore, isValidating, setSize, size]); |
||||||
|
|
||||||
|
const handleSubmit = async () => { |
||||||
|
if (comment.trim()) { |
||||||
|
const formData = new FormData(); |
||||||
|
formData.append('course_uuid', courseUuid); |
||||||
|
formData.append('content', comment); |
||||||
|
if (replyingTo) { |
||||||
|
formData.append('parent_id', replyingTo.commentId.toString()); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await fetch('/api/chat/post', { |
||||||
|
method: 'POST', |
||||||
|
body: formData |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error('Failed to post comment'); |
||||||
|
} |
||||||
|
|
||||||
|
const newComment = await response.json(); |
||||||
|
|
||||||
|
// Optimistically update the UI
|
||||||
|
const updatedData = [...(data || [])]; |
||||||
|
if (replyingTo) { |
||||||
|
// Update the discussion with the new reply
|
||||||
|
updatedData.forEach(page => { |
||||||
|
page.discussions = page.discussions.map(discussion => { |
||||||
|
if (discussion.id === replyingTo.commentId) { |
||||||
|
return { |
||||||
|
...discussion, |
||||||
|
replies: [...discussion.replies, { |
||||||
|
...newComment, |
||||||
|
timestamp: 'Just now' |
||||||
|
}] |
||||||
|
}; |
||||||
|
} |
||||||
|
return discussion; |
||||||
|
}); |
||||||
|
}); |
||||||
|
} else { |
||||||
|
// Add new top-level comment
|
||||||
|
if (updatedData[0]) { |
||||||
|
updatedData[0].discussions = [{ |
||||||
|
...newComment, |
||||||
|
replies: [], |
||||||
|
timestamp: 'Just now' |
||||||
|
}, ...updatedData[0].discussions]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await mutate(updatedData, false); |
||||||
|
setComment(''); |
||||||
|
setReplyingTo(null); |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to post comment:', error); |
||||||
|
// You might want to show an error message to the user here
|
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const handleReply = (commentId: number, userName: string) => { |
||||||
|
setReplyingTo({ commentId, userName }); |
||||||
|
setComment(`@${userName} `); |
||||||
|
}; |
||||||
|
|
||||||
|
const cancelReply = () => { |
||||||
|
setReplyingTo(null); |
||||||
|
setComment(''); |
||||||
|
}; |
||||||
|
|
||||||
|
if (error) { |
||||||
|
return ( |
||||||
|
<div className="text-red-500 p-4 text-center"> |
||||||
|
Error loading discussions. Please try again later. |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="w-full max-w-3xl mx-auto"> |
||||||
|
<ScrollArea className="h-[600px]"> |
||||||
|
<div className="space-y-6 p-4"> |
||||||
|
{allDiscussions.map((discussion) => ( |
||||||
|
<Card key={discussion.id} className="p-4"> |
||||||
|
<div className="space-y-4"> |
||||||
|
<div className="flex gap-4"> |
||||||
|
<Avatar className="w-8 h-8"> |
||||||
|
<AvatarImage src={discussion.avatar} alt={discussion.user} /> |
||||||
|
<AvatarFallback>{discussion.user[0]}</AvatarFallback> |
||||||
|
</Avatar> |
||||||
|
<div className="flex-1"> |
||||||
|
<div className="flex items-center gap-2 mb-1"> |
||||||
|
<span className="font-medium">{discussion.user}</span> |
||||||
|
<span className="text-sm text-gray-500">{discussion.timestamp}</span> |
||||||
|
</div> |
||||||
|
<p className="text-gray-700 mb-2">{discussion.content}</p> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700" |
||||||
|
onClick={() => handleReply(discussion.id, discussion.user)} |
||||||
|
> |
||||||
|
<Reply className="w-4 h-4" /> |
||||||
|
Reply |
||||||
|
</button> |
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-500"> |
||||||
|
<MessageCircle className="w-4 h-4" /> |
||||||
|
{discussion.replies.length} replies |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{discussion.replies.length > 0 && ( |
||||||
|
<div className="ml-12 space-y-4"> |
||||||
|
{discussion.replies.map((reply) => ( |
||||||
|
<div key={reply.id} className="flex gap-4"> |
||||||
|
<Avatar className="w-8 h-8"> |
||||||
|
<AvatarImage src={reply.avatar} alt={reply.user} /> |
||||||
|
<AvatarFallback>{reply.user[0]}</AvatarFallback> |
||||||
|
</Avatar> |
||||||
|
<div className="flex-1"> |
||||||
|
<div className="flex items-center gap-2 mb-1"> |
||||||
|
<span className="font-medium">{reply.user}</span> |
||||||
|
<span className="text-sm text-gray-500">{reply.timestamp}</span> |
||||||
|
</div> |
||||||
|
<p className="text-gray-700">{reply.content}</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
|
||||||
|
<div ref={loadingRef} className="h-10 flex items-center justify-center"> |
||||||
|
{isLoadingMore && ( |
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900" /> |
||||||
|
)} |
||||||
|
{!hasMore && allDiscussions.length > 0 && ( |
||||||
|
<div className="text-gray-500">No more discussions</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</ScrollArea> |
||||||
|
|
||||||
|
<div className="my-6 px-4"> |
||||||
|
<div className="flex items-start gap-4"> |
||||||
|
<Avatar className="w-8 h-8"> |
||||||
|
<AvatarImage src="/api/placeholder/32/32" alt="User" /> |
||||||
|
<AvatarFallback>U</AvatarFallback> |
||||||
|
</Avatar> |
||||||
|
<div className="flex-1"> |
||||||
|
{replyingTo && ( |
||||||
|
<div className="flex items-center gap-2 mb-2 text-sm text-gray-500"> |
||||||
|
<span>Replying to {replyingTo.userName}</span> |
||||||
|
<button
|
||||||
|
onClick={cancelReply} |
||||||
|
className="p-1 hover:bg-gray-100 rounded-full" |
||||||
|
> |
||||||
|
<X className="w-4 h-4" /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<Textarea |
||||||
|
placeholder={replyingTo ? `Reply to ${replyingTo.userName}...` : "Add to the discussion..."} |
||||||
|
value={comment} |
||||||
|
onChange={(e) => setComment(e.target.value)} |
||||||
|
className="min-h-24 mb-6" |
||||||
|
/> |
||||||
|
<div className="w-fit ml-auto"> |
||||||
|
<Button onClick={handleSubmit} className="bg-purple-700"> |
||||||
|
{replyingTo ? 'Post Reply' : 'Post Comment'} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default EnhancedDiscussionSection; |
@ -1,97 +1,73 @@ |
|||||||
|
import React, { useState } from 'react'; |
||||||
import { Button } from '@/components/ui/button'; |
import { Button } from '@/components/ui/button'; |
||||||
import React, { useEffect, useState } from 'react'; |
import { APP_BASE_URL } from '@/utils/constants'; |
||||||
|
|
||||||
export default function CustomPDFViewer() { |
const CustomPDFViewer = ({ |
||||||
const [currentPage, setCurrentPage] = useState(1); |
pdfPages , |
||||||
const [totalPages, setTotalPages] = useState(0); |
currentPage ,
|
||||||
const [loading, setLoading] = useState(true); |
onChange |
||||||
const [error, setError] = useState<string | null>(null); |
} : { |
||||||
const [pageUrl, setPageUrl] = useState(''); |
pdfPages : Array<string> |
||||||
|
currentPage : number |
||||||
|
onChange : (value : any) => void |
||||||
|
}) => { |
||||||
|
// const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
|
||||||
// Mock function to fetch PDF page as image URL
|
// Your PDF pages array
|
||||||
// In a real implementation, you would use a backend service to convert PDF pages to images
|
|
||||||
const fetchPageImage = async (pageNumber : number) => { |
|
||||||
setLoading(true); |
|
||||||
try { |
|
||||||
// Simulating API call delay
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500)); |
|
||||||
// Using placeholder image for demonstration
|
|
||||||
setPageUrl(`https://placehold.co/600x400?text=Page ${pageNumber}`); |
|
||||||
setTotalPages(5); // Mock total pages
|
|
||||||
setLoading(false); |
|
||||||
} catch (err) { |
|
||||||
setError('Failed to load PDF page'); |
|
||||||
setLoading(false); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
useEffect(() => { |
const totalPages = pdfPages?.length; |
||||||
fetchPageImage(currentPage); |
|
||||||
}, [currentPage]); |
|
||||||
|
|
||||||
const goToPreviousPage = () => { |
const goToPreviousPage = () => { |
||||||
if (currentPage > 1) { |
if (currentPage > 0) { |
||||||
setCurrentPage(prev => prev - 1); |
onChange((prev : number) => prev - 1); |
||||||
} |
} |
||||||
}; |
}; |
||||||
|
|
||||||
const goToNextPage = () => { |
const goToNextPage = () => { |
||||||
if (currentPage < totalPages) { |
if (currentPage < totalPages - 1) { |
||||||
setCurrentPage(prev => prev + 1); |
onChange((prev : number) => prev + 1); |
||||||
} |
} |
||||||
}; |
}; |
||||||
|
|
||||||
return ( |
return ( |
||||||
<div className="flex flex-col items-center w-full max-w-3xl mx-auto p-4"> |
<> |
||||||
{/* Page Display */} |
{ |
||||||
<div className="relative w-full h-auto bg-gray-100 rounded-lg shadow-lg mb-4"> |
pdfPages && ( |
||||||
{loading ? ( |
<div className="flex flex-col items-center space-y-4"> |
||||||
<div className=" inset-0 flex items-center justify-center min-h-96"> |
{/* PDF Display */} |
||||||
<div className="text-lg text-gray-600">Loading page {currentPage}...</div> |
<div className="w-full max-w-3xl border rounded-lg shadow-lg"> |
||||||
|
<iframe |
||||||
|
src={APP_BASE_URL + pdfPages[currentPage]} |
||||||
|
className="w-full h-[800px]" |
||||||
|
title={`PDF Page ${currentPage + 1}`} |
||||||
|
/> |
||||||
</div> |
</div> |
||||||
) : error ? ( |
|
||||||
<div className="absolute inset-0 flex items-center justify-center"> |
|
||||||
<div className="text-lg text-red-600">{error}</div> |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
<img
|
|
||||||
src={pageUrl}
|
|
||||||
alt={`Page ${currentPage}`} |
|
||||||
className="w-full h-auto object-contain rounded-lg" |
|
||||||
/> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
|
|
||||||
{/* Navigation Controls */} |
|
||||||
<div className="flex items-center gap-4 mt-6"> |
|
||||||
<Button |
|
||||||
onClick={goToPreviousPage} |
|
||||||
disabled={currentPage <= 1 || loading} |
|
||||||
className={`px-4 py-2 rounded-lg ${ |
|
||||||
currentPage <= 1 || loading |
|
||||||
? 'bg-purple-700/50 cursor-not-allowed' |
|
||||||
: 'bg-purple-700 hover:bg-purple-500 text-white' |
|
||||||
} transition-colors`}
|
|
||||||
> |
|
||||||
Previous |
|
||||||
</Button> |
|
||||||
|
|
||||||
<div className="text-lg font-medium"> |
{/* Navigation Controls */} |
||||||
Page {currentPage} of {totalPages} |
<div className="flex items-center space-x-4"> |
||||||
|
<Button |
||||||
|
onClick={goToPreviousPage} |
||||||
|
disabled={currentPage === 0} |
||||||
|
variant="outline" |
||||||
|
> |
||||||
|
Previous |
||||||
|
</Button> |
||||||
|
<span className="text-sm"> |
||||||
|
Page {currentPage + 1} of {totalPages} |
||||||
|
</span> |
||||||
|
<Button |
||||||
|
onClick={goToNextPage} |
||||||
|
disabled={currentPage === totalPages - 1} |
||||||
|
variant="outline" |
||||||
|
> |
||||||
|
Next |
||||||
|
</Button> |
||||||
|
</div> |
||||||
</div> |
</div> |
||||||
|
) |
||||||
<Button |
} |
||||||
onClick={goToNextPage} |
</> |
||||||
disabled={currentPage >= totalPages || loading} |
|
||||||
className={`px-4 py-2 rounded-lg ${ |
|
||||||
currentPage >= totalPages || loading |
|
||||||
? 'bg-purple-700/50 cursor-not-allowed' |
|
||||||
: 'bg-purple-700 hover:bg-purple-500 text-white' |
|
||||||
} transition-colors`}
|
|
||||||
> |
|
||||||
Next |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
); |
||||||
} |
}; |
||||||
|
|
||||||
|
export default CustomPDFViewer; |
Loading…
Reference in new issue