Files
Vault/app/dashboard/dashboard-client.tsx
T
twotalesanimation 81ad7e4ea9 Initial commit
2026-06-11 10:46:09 +02:00

485 lines
20 KiB
TypeScript

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