Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
+484
View File
@@ -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>
);
}
+225
View File
@@ -0,0 +1,225 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
interface LikedVideoItem {
id: string;
videoId: string;
createdAt: string;
video: {
id: string;
title: string;
thumbnail?: string;
durationSec?: number;
url: string;
playlist: {
title: string;
course: {
id: string;
title: string;
};
};
};
}
export default function LikedVideosClient() {
const router = useRouter();
const [likes, setLikes] = React.useState<LikedVideoItem[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
const fetchLikes = async () => {
try {
const res = await fetch('/api/likes/all');
if (res.ok) {
const data = await res.json();
setLikes(data);
}
} catch (err) {
console.error('Failed to fetch liked videos', err);
} finally {
setIsLoading(false);
}
};
fetchLikes();
}, []);
const handleVideoClick = (videoId: string) => {
router.push(`/videoplayer?videoId=${videoId}`);
};
const formatTime = (seconds?: number) => {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
if (isLoading) {
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 gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Liked Videos</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
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 gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Liked Videos</CardTitle>
</CardHeader>
<CardContent>
{likes.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">No liked videos yet</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Playlist</TableHead>
<TableHead>Course</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Liked On</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{likes.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.thumbnail ? (
<div className="relative w-24 h-14">
<Image
src={item.video.thumbnail}
alt={item.video.title}
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : (
<div className="w-24 h-14 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</div>
</TableCell>
<TableCell>
<p
className="font-medium max-w-xs truncate cursor-pointer hover:underline"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.title}
</p>
</TableCell>
<TableCell>
<span className="text-sm">
{item.video.playlist.title}
</span>
</TableCell>
<TableCell>
<span className="text-sm">
{item.video.playlist.course.title}
</span>
</TableCell>
<TableCell>
<span className="text-sm">
{formatTime(item.video.durationSec)}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{formatDate(item.createdAt)}
</span>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => handleVideoClick(item.video.id)}
>
Watch
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+20
View File
@@ -0,0 +1,20 @@
// app/dashboard/liked-videos/page.tsx
import { Metadata } from 'next';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import LikedVideosClient from '../liked-videos-client';
export const metadata: Metadata = {
title: 'Liked Videos | CMS',
description: 'View your liked videos',
};
export default async function LikedVideosPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect('/login');
}
return <LikedVideosClient />;
}
+11
View File
@@ -0,0 +1,11 @@
// app/dashboard/page.tsx (server component)
import { requireUser } from '@/lib/auth-check';
import DashboardClient from './dashboard-client'; // will be your existing client UI
export default async function DashboardPage() {
// will redirect to /login if not authenticated
const session = await requireUser('/login');
// you can optionally pass session to client via props if desired:
return <DashboardClient />;
}
+210
View File
@@ -0,0 +1,210 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
interface WatchHistoryItem {
id: string;
videoId: string;
lastPos?: number;
updatedAt: string;
video: {
id: string;
title: string;
thumbnail?: string;
durationSec?: number;
url: string;
};
}
export default function WatchHistoryClient() {
const router = useRouter();
const [history, setHistory] = React.useState<WatchHistoryItem[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
const fetchHistory = async () => {
try {
const res = await fetch('/api/watch-history');
if (res.ok) {
const data = await res.json();
setHistory(data);
}
} catch (err) {
console.error('Failed to fetch watch history', err);
} finally {
setIsLoading(false);
}
};
fetchHistory();
}, []);
const handleVideoClick = (videoId: string) => {
router.push(`/videoplayer?videoId=${videoId}`);
};
const formatTime = (seconds?: number) => {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (isLoading) {
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 gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Watch History</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
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 gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Watch History</CardTitle>
</CardHeader>
<CardContent>
{history.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">No videos watched yet</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Last Position</TableHead>
<TableHead>Last Watched</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.thumbnail ? (
<div className="relative w-24 h-14">
<Image
src={item.video.thumbnail}
alt={item.video.title}
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : (
<div className="w-24 h-14 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</div>
</TableCell>
<TableCell>
<p
className="font-medium max-w-xs truncate cursor-pointer hover:underline"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.title}
</p>
</TableCell>
<TableCell>
<span className="text-sm">
{formatTime(item.lastPos)} /{' '}
{formatTime(item.video.durationSec)}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{formatDate(item.updatedAt)}
</span>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => handleVideoClick(item.video.id)}
>
Resume
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+20
View File
@@ -0,0 +1,20 @@
// app/dashboard/watch-history/page.tsx
import { Metadata } from 'next';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import WatchHistoryClient from '../watch-history-client';
export const metadata: Metadata = {
title: 'Watch History | CMS',
description: 'View your video watch history',
};
export default async function WatchHistoryPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect('/login');
}
return <WatchHistoryClient />;
}