Initial commit
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { usePlaylists } from '@/hooks/usePlaylists';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '@/components/ui/carousel';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
|
||||
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { Lock } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
type VideoItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
durationSec?: number;
|
||||
thumbnail?: string;
|
||||
locked?: boolean;
|
||||
instantAccess?: boolean;
|
||||
uploader?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type VideoProgressData = {
|
||||
percent: number;
|
||||
watchedSec: number;
|
||||
segments: WatchSegment[];
|
||||
};
|
||||
|
||||
export default function DashboardClient() {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const { subjects, isLoading } = usePlaylists();
|
||||
const [videoProgress, setVideoProgress] = React.useState<Record<string, VideoProgressData>>({});
|
||||
const [userUnlocks, setUserUnlocks] = React.useState<Set<string>>(new Set());
|
||||
const [latestVideos, setLatestVideos] = React.useState<any[]>([]);
|
||||
const [latestVideosLoading, setLatestVideosLoading] = React.useState(true);
|
||||
const playlistsToShow = React.useMemo(() => {
|
||||
const playlistMap = new Map<
|
||||
string,
|
||||
{
|
||||
playlist: any;
|
||||
courses: Map<string, { id: string; title?: string; code?: string }>;
|
||||
}
|
||||
>();
|
||||
|
||||
subjects.forEach((subject: any) => {
|
||||
(subject.playlists || []).forEach((playlist: any) => {
|
||||
const entry = playlistMap.get(playlist.id);
|
||||
const courseInfo = {
|
||||
id: subject.id,
|
||||
title: subject.title,
|
||||
code: subject.code,
|
||||
};
|
||||
|
||||
if (entry) {
|
||||
entry.courses.set(courseInfo.id, courseInfo);
|
||||
} else {
|
||||
const coursesMap = new Map<string, { id: string; title?: string; code?: string }>();
|
||||
coursesMap.set(courseInfo.id, courseInfo);
|
||||
playlistMap.set(playlist.id, { playlist, courses: coursesMap });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(playlistMap.values()).map(({ playlist, courses }) => ({
|
||||
...playlist,
|
||||
coursesForDisplay: Array.from(courses.values()),
|
||||
}));
|
||||
}, [subjects]);
|
||||
|
||||
// Fetch user's unlocks
|
||||
React.useEffect(() => {
|
||||
const fetchUserUnlocks = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/user/unlocks');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]);
|
||||
setUserUnlocks(unlockedVideoIds);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user unlocks:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserUnlocks();
|
||||
}, []);
|
||||
|
||||
// Fetch latest videos from enrolled courses
|
||||
React.useEffect(() => {
|
||||
const fetchLatestVideos = async () => {
|
||||
try {
|
||||
setLatestVideosLoading(true);
|
||||
const res = await fetch('/api/videos/latest');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setLatestVideos(data.videos ?? []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch latest videos:', err);
|
||||
} finally {
|
||||
setLatestVideosLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestVideos();
|
||||
}, []);
|
||||
|
||||
// Auto-sync enrollments for allowed students on first login
|
||||
React.useEffect(() => {
|
||||
const syncEnrollments = async () => {
|
||||
if (!session?.user) return;
|
||||
|
||||
const userLevels = (session.user as any)?.levels;
|
||||
console.log('[DASHBOARD] User levels from session:', userLevels);
|
||||
|
||||
if (!userLevels) {
|
||||
console.log('[DASHBOARD] No levels found in session');
|
||||
return;
|
||||
}
|
||||
|
||||
const levels = userLevels
|
||||
.split(',')
|
||||
.map((level: string) => level.trim())
|
||||
.filter((level: string) => level.length > 0);
|
||||
|
||||
console.log('[DASHBOARD] Parsed levels:', levels);
|
||||
|
||||
if (levels.length === 0) {
|
||||
console.log('[DASHBOARD] No valid levels after parsing');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[DASHBOARD] Calling sync-enrollments with:', { levels });
|
||||
const res = await fetch('/api/enrollments/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ levels }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('[DASHBOARD] Failed to sync enrollments, status:', res.status);
|
||||
} else {
|
||||
const result = await res.json();
|
||||
console.log('[DASHBOARD] Sync enrollments result:', result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[DASHBOARD] Error syncing enrollments:', err);
|
||||
}
|
||||
};
|
||||
|
||||
syncEnrollments();
|
||||
}, [session]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchProgress = async () => {
|
||||
const progressMap: Record<string, VideoProgressData> = {};
|
||||
|
||||
// Fetch progress for playlist videos
|
||||
for (const playlist of playlistsToShow) {
|
||||
for (const video of playlist.videos || []) {
|
||||
try {
|
||||
const [progressRes, segmentsRes] = await Promise.all([
|
||||
fetch(`/api/progress?videoId=${video.id}`),
|
||||
fetch(`/api/progress/segments?videoId=${video.id}`),
|
||||
]);
|
||||
|
||||
if (progressRes.ok) {
|
||||
const progressData = await progressRes.json();
|
||||
const segmentsData = segmentsRes.ok ? await segmentsRes.json() : { segments: [] };
|
||||
progressMap[video.id] = {
|
||||
percent: progressData.percent ?? 0,
|
||||
watchedSec: progressData.watchedSec ?? 0,
|
||||
segments: segmentsData.segments ?? [],
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch progress for video ${video.id}`, err);
|
||||
progressMap[video.id] = { percent: 0, watchedSec: 0, segments: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch progress for latest videos
|
||||
for (const video of latestVideos) {
|
||||
try {
|
||||
const [progressRes, segmentsRes] = await Promise.all([
|
||||
fetch(`/api/progress?videoId=${video.id}`),
|
||||
fetch(`/api/progress/segments?videoId=${video.id}`),
|
||||
]);
|
||||
|
||||
if (progressRes.ok) {
|
||||
const progressData = await progressRes.json();
|
||||
const segmentsData = segmentsRes.ok ? await segmentsRes.json() : { segments: [] };
|
||||
progressMap[video.id] = {
|
||||
percent: progressData.percent ?? 0,
|
||||
watchedSec: progressData.watchedSec ?? 0,
|
||||
segments: segmentsData.segments ?? [],
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch progress for video ${video.id}`, err);
|
||||
progressMap[video.id] = { percent: 0, watchedSec: 0, segments: [] };
|
||||
}
|
||||
}
|
||||
|
||||
setVideoProgress(progressMap);
|
||||
};
|
||||
|
||||
if (playlistsToShow.length > 0 || latestVideos.length > 0) {
|
||||
fetchProgress();
|
||||
}
|
||||
}, [playlistsToShow, latestVideos]);
|
||||
|
||||
const handleOpenVideo = (playlistId: string, videoId: string) => {
|
||||
// navigate to videoplayer page using query params (keeps your current structure)
|
||||
router.push(`/videoplayer?playlistId=${playlistId}&videoId=${videoId}`);
|
||||
};
|
||||
|
||||
const formatDuration = (s?: number) => {
|
||||
if (!s && s !== 0) return '';
|
||||
const mins = Math.floor(s! / 60);
|
||||
const secs = Math.floor(s! % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
return `${mins}:${secs}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': 'calc(var(--spacing) * 72)',
|
||||
'--header-height': 'calc(var(--spacing) * 12)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
{isLoading && latestVideosLoading ? (
|
||||
<div className="px-4 lg:px-16">Loading…</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Latest Videos Section */}
|
||||
{!latestVideosLoading && latestVideos.length > 0 && (
|
||||
<div className="px-4 lg:px-16">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-base font-medium">Latest Videos</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recently uploaded from your enrolled courses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Carousel opts={{ align: 'start' }} className="w-full">
|
||||
<CarouselContent>
|
||||
{latestVideos.map((v: any) => (
|
||||
<CarouselItem
|
||||
key={v.id}
|
||||
className="md:basis-1/3 lg:basis-1/5"
|
||||
>
|
||||
<Card
|
||||
className="@container/card overflow-hidden cursor-pointer"
|
||||
onClick={() => {
|
||||
if (v.playlist?.id) {
|
||||
handleOpenVideo(v.playlist.id, v.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardHeader className='px-2'>
|
||||
<div className="relative overflow-hidden rounded-sm aspect-video bg-gray-100">
|
||||
<img
|
||||
src={v.thumbnail ?? '/01.jpg'}
|
||||
alt={v.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-start pt-2">
|
||||
{v.uploader?.image && (
|
||||
<div className="relative w-9 h-9 rounded-full overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={v.uploader.image}
|
||||
alt={v.uploader.name ?? 'Uploader'}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<CardTitle className="text-sm font-medium truncate">
|
||||
{v.title}
|
||||
</CardTitle>
|
||||
<Badge className="shrink-0">
|
||||
{formatDuration(v.durationSec)}
|
||||
</Badge>
|
||||
</div>
|
||||
{v.uploader?.name && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{v.uploader.name}
|
||||
</p>
|
||||
)}
|
||||
{v.playlist?.title && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{v.playlist.title}
|
||||
</p>
|
||||
)}
|
||||
<SegmentedProgressBar
|
||||
className='mt-2'
|
||||
segments={videoProgress[v.id]?.segments ?? []}
|
||||
duration={v.durationSec ?? 1}
|
||||
percent={videoProgress[v.id]?.percent ?? 0}
|
||||
height="sm"
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playlists Section */}
|
||||
{playlistsToShow.length === 0 && latestVideos.length === 0 ? (
|
||||
<div className="px-4 lg:px-16">No content available.</div>
|
||||
) : (
|
||||
<>
|
||||
{playlistsToShow.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 lg:px-16">
|
||||
<h2 className="text-base font-medium">Playlists</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing {playlistsToShow.length} playlist{playlistsToShow.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
{playlistsToShow.map((pl: any) => (
|
||||
<div key={pl.id} className="px-4 lg:px-16 mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{pl.title}</h3>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pl.description}
|
||||
</div>
|
||||
{pl.coursesForDisplay?.length ? (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{pl.coursesForDisplay.map((course: any) => (
|
||||
<Badge key={course.id} variant="outline">
|
||||
{course.code ?? course.title}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Carousel opts={{ align: 'start' }} className="w-full">
|
||||
<CarouselContent>
|
||||
{pl.videos?.map((v: VideoItem) => (
|
||||
<CarouselItem
|
||||
key={v.id}
|
||||
className="md:basis-1/3 lg:basis-1/5"
|
||||
>
|
||||
<Card
|
||||
className="@container/card overflow-hidden cursor-pointer"
|
||||
onClick={() => {
|
||||
if ((v as any).locked) return;
|
||||
if ((v as any).instantAccess) {
|
||||
handleOpenVideo(pl.id, v.id);
|
||||
return;
|
||||
}
|
||||
if ((v as any).index === 0) {
|
||||
handleOpenVideo(pl.id, v.id);
|
||||
return;
|
||||
}
|
||||
if (userUnlocks.has(v.id)) {
|
||||
handleOpenVideo(pl.id, v.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardHeader className='px-2'>
|
||||
<div className="relative overflow-hidden rounded-sm aspect-video bg-gray-100">
|
||||
<img
|
||||
src={v.thumbnail ?? '/01.jpg'}
|
||||
alt={v.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<Lock className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2 items-start pt-2">
|
||||
{(v as any).uploader?.image && (
|
||||
<div className="relative w-9 h-9 rounded-full overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={(v as any).uploader.image}
|
||||
alt={(v as any).uploader.name ?? 'Uploader'}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<CardTitle className="text-sm font-medium truncate">
|
||||
{v.title}
|
||||
</CardTitle>
|
||||
<Badge className="shrink-0">
|
||||
{formatDuration(v.durationSec)}
|
||||
</Badge>
|
||||
</div>
|
||||
{(v as any).uploader?.name && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{(v as any).uploader.name}
|
||||
</p>
|
||||
)}
|
||||
<SegmentedProgressBar
|
||||
className='mt-2'
|
||||
segments={videoProgress[v.id]?.segments ?? []}
|
||||
duration={v.durationSec ?? 1}
|
||||
percent={videoProgress[v.id]?.percent ?? 0}
|
||||
height="sm"
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user