first commit

This commit is contained in:
gotolombok
2026-01-31 17:21:32 +08:00
commit f839fbd63a
109 changed files with 19204 additions and 0 deletions

25
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
'use client';
import AdminSidebar from '@/components/admin/AdminSidebar';
import AdminHeader from '@/components/admin/AdminHeader';
import { Toaster } from '@/components/ui/sonner';
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-[#0a0a0a] text-white flex">
<AdminSidebar />
<div className="flex-1 flex flex-col md:ml-64 transition-all duration-300">
<AdminHeader />
<main className="flex-1 p-6 overflow-y-auto">
{children}
</main>
<Toaster />
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Season } from '@/lib/api';
import { useMemo, useState, useEffect } from 'react';
interface EpisodeSelectorProps {
seasons: Season[];
currentSeason: number;
currentEpisode: number;
}
export function EpisodeSelector({ seasons, currentSeason, currentEpisode }: EpisodeSelectorProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Default to first season if currentSeason is 0
const [selectedSeason, setSelectedSeason] = useState<string>(
currentSeason > 0 ? currentSeason.toString() : (seasons[0]?.se.toString() || '1')
);
const activeSeason = useMemo(() =>
seasons.find(s => s.se.toString() === selectedSeason),
[seasons, selectedSeason]
);
const episodes = useMemo(() => {
if (!activeSeason) return [];
return Array.from({ length: activeSeason.maxEp }, (_, i) => i + 1);
}, [activeSeason]);
const handleSeasonChange = (val: string) => {
setSelectedSeason(val);
// When season changes, we don't necessarily update URL until episode is picked?
// Or we can just reset episode logic.
// Let's just update local state, user must pick episode.
};
const handleEpisodeChange = (val: string) => {
const params = new URLSearchParams(searchParams);
params.set('season', selectedSeason);
params.set('episode', val);
router.push(`${pathname}?${params.toString()}`);
};
// Update local state if prop changes (e.g. navigation)
useEffect(() => {
if (currentSeason > 0) {
setSelectedSeason(currentSeason.toString());
}
}, [currentSeason]);
return (
<div className="flex flex-wrap gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-800">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">Season</span>
<Select value={selectedSeason} onValueChange={handleSeasonChange}>
<SelectTrigger className="w-[140px] bg-gray-950 border-gray-700">
<SelectValue placeholder="Select Season" />
</SelectTrigger>
<SelectContent>
{seasons.map((season) => (
<SelectItem key={season.se} value={season.se.toString()}>
Season {season.se}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">Episode</span>
<Select
value={currentEpisode > 0 && currentSeason.toString() === selectedSeason ? currentEpisode.toString() : ''}
onValueChange={handleEpisodeChange}
disabled={!activeSeason}
>
<SelectTrigger className="w-[140px] bg-gray-950 border-gray-700">
<SelectValue placeholder="Select Episode" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{episodes.map((ep) => (
<SelectItem key={ep} value={ep.toString()}>
Episode {ep}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { getMovieDetail, getSources, SourceData, Subject, Resource } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import Image from 'next/image';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { MoveLeft, PlayCircle, Download } from 'lucide-react';
import Link from 'next/link';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
// ... imports
import { EpisodeSelector } from '@/components/admin/EpisodeSelector';
interface MovieDetailPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ season?: string; episode?: string }>;
}
export default async function MovieDetailPage({ params, searchParams }: MovieDetailPageProps) {
const { id } = await params;
const { season, episode } = await searchParams;
let movie: Subject | null = null;
let resource: Resource | undefined = undefined;
let sources: SourceData[] = [];
let errorMsg = '';
// Parse params or default to 0
let currentSeason = season ? parseInt(season) : 0;
let currentEpisode = episode ? parseInt(episode) : 0;
// Valid flags
let isSeries = false;
let showSources = false;
try {
const detail = await getMovieDetail(id);
movie = detail.subject;
resource = detail.resource;
isSeries = movie.subjectType === 2;
if (isSeries) {
// For Series, only fetch if season/episode are explicitly selected
if (currentSeason > 0 && currentEpisode > 0) {
const sourceData = await getSources(id, movie.detailPath, currentSeason, currentEpisode);
sources = sourceData.sources || [];
showSources = true;
} else {
// Nothing selected yet, or invalid
showSources = false;
}
} else {
// Movies: always fetch 0,0
const sourceData = await getSources(id, movie.detailPath, 0, 0);
sources = sourceData.sources || [];
showSources = true;
}
} catch (e: any) {
console.error("Error fetching detail:", e);
errorMsg = e.message;
}
if (errorMsg || !movie) {
return (
<div className="space-y-6">
<Link href="/admin/movies">
<Button variant="ghost" className="gap-2 text-gray-400 hover:text-white">
<MoveLeft className="h-4 w-4" /> Back to Movies
</Button>
</Link>
<div className="p-4 text-red-500 bg-red-500/10 rounded border border-red-500/20">
Error loading movie: {errorMsg}
</div>
</div>
);
}
return (
<div className="space-y-6 max-w-5xl mx-auto">
<div className="flex items-center gap-4">
<Link href="/admin/movies">
<Button variant="outline" size="icon" className="h-9 w-9 border-gray-700 bg-transparent text-gray-400 hover:text-white hover:bg-gray-800">
<MoveLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight text-white line-clamp-1">{movie.title}</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-6">
{/* Poster Column */}
<div className="space-y-4">
<div className="relative aspect-[2/3] rounded-lg overflow-hidden border border-gray-800 shadow-xl">
<Image
src={movie.cover?.url || (movie.image?.url ?? '/placeholder.png')}
alt={movie.title}
fill
className="object-cover"
priority
/>
</div>
</div>
{/* Details Column */}
<div className="space-y-6">
<Card className="bg-[#141414] border-gray-800 text-white">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-500 mb-1">Synopsis</h3>
<p className="text-gray-300 leading-relaxed text-sm">
{movie.description || "No description available."}
</p>
</div>
<Separator className="bg-gray-800" />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<h3 className="text-gray-500 mb-1">Release Date</h3>
<p>{movie.releaseDate || 'Unknown'}</p>
</div>
<div>
<h3 className="text-gray-500 mb-1">IMDb Rating</h3>
<div className="flex items-center gap-1">
<span className="text-yellow-500 font-bold">{movie.imdbRatingValue}</span>
</div>
</div>
<div>
<h3 className="text-gray-500 mb-1">Genre</h3>
<p>{movie.genre || 'Unknown'}</p>
</div>
<div>
<h3 className="text-gray-500 mb-1">Country</h3>
<p>{movie.countryName || 'Unknown'}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#141414] border-gray-800 text-white">
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<CardTitle>Streaming Sources</CardTitle>
<CardDescription>Available video sources for playback.</CardDescription>
</div>
{isSeries && currentSeason > 0 && (
<Badge variant="outline" className="text-primary border-primary/50">
Season {currentSeason} - Episode {currentEpisode}
</Badge>
)}
</div>
{isSeries && resource?.seasons && (
<EpisodeSelector
seasons={resource.seasons}
currentSeason={currentSeason}
currentEpisode={currentEpisode}
/>
)}
</div>
</CardHeader>
<CardContent>
{!showSources && isSeries ? (
<div className="text-center py-8 text-gray-500">
Please select an episode to view sources.
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-gray-800 hover:bg-transparent">
<TableHead className="text-gray-400">Quality</TableHead>
<TableHead className="text-gray-400">Size</TableHead>
<TableHead className="text-gray-400 text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sources.map((source, idx) => (
<TableRow key={idx} className="border-gray-800 hover:bg-gray-900/50">
<TableCell className="font-medium">
<Badge variant="secondary" className="bg-gray-800 hover:bg-gray-700">
{source.resolution}p
</Badge>
</TableCell>
<TableCell className="text-gray-400 font-mono text-xs">{source.size || '-'}</TableCell>
<TableCell className="text-right">
<Link href={source.url} target="_blank">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-primary hover:text-primary hover:bg-primary/10">
<PlayCircle className="h-4 w-4" />
</Button>
</Link>
</TableCell>
</TableRow>
))}
{sources.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center text-gray-500 py-4">
No sources found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

113
app/admin/movies/page.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { getTrending, Subject } from '@/lib/api';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Pagination } from '@/components/admin/Pagination';
import Image from 'next/image';
import Link from 'next/link';
import { Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface MoviesPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function MoviesPage({ searchParams }: MoviesPageProps) {
const params = await searchParams;
const page = typeof params.page === 'string' ? parseInt(params.page) : 1;
let movies: Subject[] = [];
let hasMore = false;
let errorMsg = '';
try {
const { subjectList, pager } = await getTrending(page);
movies = subjectList;
hasMore = pager.hasMore;
} catch (e: any) {
console.error("Error fetching movies:", e);
errorMsg = e.message;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight text-white">Data Film</h1>
</div>
<Card className="bg-[#141414] border-gray-800 text-white">
<CardHeader>
<CardTitle>Movie List</CardTitle>
</CardHeader>
<CardContent>
{errorMsg ? (
<div className="p-4 text-red-500 bg-red-500/10 rounded border border-red-500/20">
Error: {errorMsg}
</div>
) : (
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow className="border-gray-800 hover:bg-transparent">
<TableHead className="text-gray-400 w-[100px]">Poster</TableHead>
<TableHead className="text-gray-400">Title</TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400">Type</TableHead>
<TableHead className="text-gray-400 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movies.map((movie) => (
<TableRow key={movie.subjectId} className="border-gray-800 hover:bg-gray-900/50">
<TableCell>
<div className="relative w-12 h-16 rounded overflow-hidden">
<Image
src={movie.cover?.url || '/placeholder.png'}
alt={movie.title}
fill
className="object-cover"
/>
</div>
</TableCell>
<TableCell className="font-medium">{movie.title}</TableCell>
<TableCell className="text-gray-500 text-sm font-mono">{movie.subjectId}</TableCell>
<TableCell>
<Badge variant="outline" className="border-gray-600 text-gray-300">
{movie.subjectType === 1 ? 'Movie' : 'Series'}
</Badge>
</TableCell>
<TableCell className="text-right">
<Link href={`/admin/movies/${movie.subjectId}`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<Eye className="h-4 w-4" />
</Button>
</Link>
</TableCell>
</TableRow>
))}
{movies.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-gray-500 py-8">
No movies found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination currentPage={page} hasMore={hasMore} />
</div>
)}
</CardContent>
</Card>
</div>
);
}

93
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Users, Film, Activity } from 'lucide-react';
import { createAdminClient } from '@/lib/supabase/admin';
import { getRank } from '@/lib/api';
export default async function AdminDashboard() {
// 1. Fetch User Count
let userCount = 0;
try {
const supabase = createAdminClient();
const { data, error } = await supabase.auth.admin.listUsers({ perPage: 1 });
if (data) {
// Supabase listUsers returns total if requesting?
// Actually perPage: 1 returns data.users which is array.
// Does it return total?
// Checking docs/types: ListUsersResponse usually contains 'total'.
// If not, I might need page 1 and no perPage or separate count.
// listUsers returns { users: User[], aud: string } - IT DOES NOT RETURN TOTAL in some versions.
// Wait, Supabase Admin API usually wraps it.
// Let's assume for now I might have to fetch all or check if metadata has it.
// Actually, `listUsers` returns `User[]` in the data property in older versions?
// Checking supabase-js v2: `listUsers` returns `ListUsersResponse` which has `users` and `total`.
// Ideally `total` is available.
// TypeScript check might be needed.
// Let's rely on 'total' being there or fallback to users.length (which is 1 if paged).
// If perPage=1, total should be the total count.
userCount = (data as any).total || 0;
}
} catch (e) {
console.error("Error fetching user count", e);
}
// 2. Fetch Rank Data for Top Content
let topMovie: any = null;
let topSeries: any = null;
try {
const rank = await getRank();
if (rank.movie && rank.movie.length > 0) topMovie = rank.movie[0];
if (rank.tv && rank.tv.length > 0) topSeries = rank.tv[0];
} catch (e) {
console.error("Error fetching rank", e);
}
// Fallback if no data
const movieTitle = topMovie ? topMovie.title : 'N/A';
const movieRating = topMovie ? topMovie.imdbRatingValue : '-';
const seriesTitle = topSeries ? topSeries.title : 'N/A';
const seriesRating = topSeries ? topSeries.imdbRatingValue : '-';
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold tracking-tight text-white">Dashboard</h2>
<div className="grid gap-4 md:grid-cols-3">
<Card className="bg-[#141414] border-gray-800 text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-400">Total Users</CardTitle>
<Users className="h-4 w-4 text-gray-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{userCount}</div>
<p className="text-xs text-gray-500">Registered users</p>
</CardContent>
</Card>
<Card className="bg-[#141414] border-gray-800 text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-400">Top Rated Movie</CardTitle>
<Film className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-xl font-bold truncate" title={movieTitle}>{movieTitle}</div>
<p className="text-xs text-gray-500">IMDb Rating: <span className="text-yellow-500 font-bold">{movieRating}</span></p>
</CardContent>
</Card>
<Card className="bg-[#141414] border-gray-800 text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-400">Top Rated Series</CardTitle>
<Activity className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-xl font-bold truncate" title={seriesTitle}>{seriesTitle}</div>
<p className="text-xs text-gray-500">IMDb Rating: <span className="text-yellow-500 font-bold">{seriesRating}</span></p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import { updateUserRole } from './actions';
interface RoleSelectProps {
userId: string;
initialRole: string;
currentUserEmail?: string;
}
export function RoleSelect({ userId, initialRole, currentUserEmail }: RoleSelectProps) {
const [role, setRole] = useState(initialRole);
const [loading, setLoading] = useState(false);
const handleRoleChange = async (newRole: 'admin' | 'user') => {
setLoading(true);
// Optimistic update
setRole(newRole);
const result = await updateUserRole(userId, newRole);
if (result.error) {
toast.error(`Failed to update role: ${result.error}`);
// Revert on error
setRole(initialRole);
} else {
toast.success(`Role updated to ${newRole}`);
}
setLoading(false);
};
return (
<Select
value={role}
onValueChange={(val) => handleRoleChange(val as 'admin' | 'user')}
disabled={loading}
>
<SelectTrigger className="w-[100px] h-8">
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin" className="text-red-500 font-medium">Admin</SelectItem>
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,31 @@
'use server';
import { createAdminClient } from '@/lib/supabase/admin';
import { revalidatePath } from 'next/cache';
export async function updateUserRole(userId: string, newRole: 'admin' | 'user') {
try {
const supabase = createAdminClient();
// Security check: Ensure we can't demote the last admin or ourselves indiscriminately?
// For now, let's just do the update.
// ideally we should check if the user requesting this is an admin,
// but this action is used in a protected route (middleware covers it).
const { error } = await supabase.auth.admin.updateUserById(
userId,
{ app_metadata: { role: newRole } }
);
if (error) {
console.error('Error updating role:', error);
return { error: error.message };
}
revalidatePath('/admin/roles');
return { success: true };
} catch (error: any) {
console.error('Unexpected error updating role:', error);
return { error: error.message };
}
}

90
app/admin/roles/page.tsx Normal file
View File

@@ -0,0 +1,90 @@
import { createAdminClient } from '@/lib/supabase/admin';
import { User } from '@supabase/supabase-js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RoleSelect } from './RoleSelect';
export default async function RolesPage() {
const supabase = createAdminClient();
let users: User[] = [];
let errorMsg = '';
try {
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is missing in .env.local");
}
const { data, error } = await supabase.auth.admin.listUsers();
if (error) throw error;
users = data.users || [];
} catch (e: any) {
console.error("Error fetching users:", e);
errorMsg = e.message;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight text-white">Roles Management</h1>
</div>
<Card className="bg-[#141414] border-gray-800 text-white">
<CardHeader>
<CardTitle>User Roles</CardTitle>
</CardHeader>
<CardContent>
{errorMsg ? (
<div className="p-4 text-red-500 bg-red-500/10 rounded border border-red-500/20">
Error: {errorMsg}. <br />
<span className="text-sm text-gray-400">Make sure SUPABASE_SERVICE_ROLE_KEY is set in .env.local</span>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-gray-800 hover:bg-transparent">
<TableHead className="text-gray-400">Email</TableHead>
<TableHead className="text-gray-400">Role</TableHead>
<TableHead className="text-gray-400">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => {
const role = user.app_metadata?.role || 'user';
return (
<TableRow key={user.id} className="border-gray-800 hover:bg-gray-900/50">
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>
<RoleSelect userId={user.id} initialRole={role} />
</TableCell>
<TableCell>
{user.confirmed_at ? (
<span className="text-green-500 text-sm">Confirmed</span>
) : (
<span className="text-yellow-500 text-sm">Pending</span>
)}
</TableCell>
</TableRow>
);
})}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center text-gray-500 py-8">
No users found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

91
app/admin/users/page.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { createAdminClient } from '@/lib/supabase/admin';
import { User } from '@supabase/supabase-js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; // I might need to install badge, but I'll check/skip for now or use plain HTML
export default async function UsersPage() {
// We need to handle the case where SERVICE_ROLE_KEY is missing gracefully-ish or just fail
let users: User[] = [];
let errorMsg = '';
try {
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is missing in .env.local");
}
const supabase = createAdminClient();
const { data, error } = await supabase.auth.admin.listUsers();
if (error) throw error;
users = data.users || [];
} catch (e: any) {
console.error("Error fetching users:", e);
errorMsg = e.message;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight text-white">Users</h1>
</div>
<Card className="bg-[#141414] border-gray-800 text-white">
<CardHeader>
<CardTitle>User List</CardTitle>
</CardHeader>
<CardContent>
{errorMsg ? (
<div className="p-4 text-red-500 bg-red-500/10 rounded border border-red-500/20">
Error: {errorMsg}. <br />
<span className="text-sm text-gray-400">Make sure SUPABASE_SERVICE_ROLE_KEY is set in .env.local</span>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-gray-800 hover:bg-transparent">
<TableHead className="text-gray-400">Email</TableHead>
<TableHead className="text-gray-400">Created At</TableHead>
<TableHead className="text-gray-400">Last Sign In</TableHead>
<TableHead className="text-gray-400">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id} className="border-gray-800 hover:bg-gray-900/50">
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{new Date(user.created_at).toLocaleDateString()}</TableCell>
<TableCell>
{user.last_sign_in_at
? new Date(user.last_sign_in_at).toLocaleDateString()
: 'Never'}
</TableCell>
<TableCell>
{user.confirmed_at ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-500">Confirmed</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-500">Pending</span>
)}
</TableCell>
</TableRow>
))}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-gray-500 py-8">
No users found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
// if "next" is in param, use it as the redirect URL
const next = searchParams.get('next') ?? '/'
if (code) {
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
cookieStore.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
cookieStore.delete({ name, ...options })
},
},
}
)
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
// return the user to an error page with instructions
return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}

84
app/dracin/[id]/page.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { getDracinDetail } from '@/lib/dramabox';
import Image from 'next/image';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
import DracinEpisodeList from '@/components/DracinEpisodeList';
import BookmarkButton from '@/components/BookmarkButton';
interface DracinDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function DracinDetailPage({ params }: DracinDetailPageProps) {
const { id } = await params;
const { drama, chapters } = await getDracinDetail(id);
if (!drama) {
return <div className="pt-24 text-center text-white">Drama not found.</div>;
}
return (
<div className="pt-24 px-4 md:px-16 min-h-screen">
<div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8">
{/* Cover Image */}
<div className="relative aspect-[2/3] w-full max-w-[300px] mx-auto md:mx-0 rounded-lg overflow-hidden border border-gray-800 shadow-xl">
<Image
src={drama.cover || '/placeholder.png'}
alt={drama.name || 'Drama Cover'}
fill
className="object-cover"
priority
unoptimized
/>
</div>
{/* Details */}
<div className="space-y-6 text-white">
<div>
<div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold">{drama.name}</h1>
<BookmarkButton
id={id}
type="dracin"
title={drama.name}
poster={drama.cover || ''}
/>
</div>
<div className="flex gap-2 mb-4 flex-wrap">
{drama.tags && drama.tags.map(tag => (
<Badge key={tag.tagId} variant="secondary" className="bg-gray-800">{tag.tagName}</Badge>
))}
</div>
{/* Episode List */}
<Card className="bg-[#141414] border-gray-800 text-white mt-4 mb-6">
<CardHeader>
<CardTitle>Episodes</CardTitle>
</CardHeader>
<CardContent>
<DracinEpisodeList chapters={chapters} bookId={id} />
</CardContent>
</Card>
<p className="text-gray-400">{drama.introduction}</p>
</div>
<div className="flex gap-8 text-sm text-gray-400 border-y border-gray-800 py-4">
<div>
<span className="block font-bold text-white mb-1">Chapters</span>
{drama.chapterCount}
</div>
<div>
<span className="block font-bold text-white mb-1">Views</span>
{drama.playCount.toLocaleString()}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { getDracinCategories } from '@/lib/dramabox';
import Link from 'next/link';
export default async function DracinCategoriesPage() {
const categories = await getDracinCategories();
return (
<div className="pt-24 px-4 md:px-16 min-h-screen">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2">Categories</h1>
<p className="text-gray-400">Browse by category.</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{categories.length > 0 ? (
categories.map((cat) => (
<Link
key={cat.id}
href={`/dracin/category/${cat.id}`}
className="bg-[#141414] border border-gray-800 hover:border-[#E50914] text-gray-300 hover:text-white p-4 rounded-md transition-colors flex items-center justify-center text-center font-medium"
>
{cat.name}
</Link>
))
) : (
<div className="col-span-full text-center text-gray-500 py-20">
No categories found.
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { getDracinCategoryDetail } from '@/lib/dramabox';
import DracinCard from '@/components/DracinCard';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { redirect } from 'next/navigation';
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string }>;
}
export default async function DracinCategoryDetailPage(props: PageProps) {
const params = await props.params;
const searchParams = await props.searchParams;
if (!params.id) redirect('/dracin/categories');
const page = Number(searchParams.page) || 1;
const { books, categoryName, totalPages } = await getDracinCategoryDetail(params.id, page, 20);
return (
<div className="pt-24 px-4 md:px-16 min-h-screen">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2">{categoryName || `Category ${params.id}`}</h1>
<p className="text-gray-400">Browse dramas in this category.</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{books.length > 0 ? (
books.map((item, idx) => (
<DracinCard key={`${item.id}-${idx}`} item={item} />
))
) : (
<div className="col-span-full text-center text-gray-500 py-20">
No dramas found in this category.
</div>
)}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex justify-center gap-4 mt-8 pb-8">
<Button
disabled={page <= 1}
variant="outline"
className="bg-zinc-800 text-white border-zinc-700 hover:bg-zinc-700 hover:text-white disabled:opacity-50"
asChild
>
{page > 1 ? (
<Link href={`/dracin/category/${params.id}?page=${page - 1}`}>
Previous
</Link>
) : (
<span>Previous</span>
)}
</Button>
<span className="flex items-center text-white px-4">
Page {page} of {totalPages}
</span>
<Button
disabled={page >= totalPages}
variant="outline"
className="bg-zinc-800 text-white border-zinc-700 hover:bg-zinc-700 hover:text-white disabled:opacity-50"
asChild
>
{page < totalPages ? (
<Link href={`/dracin/category/${params.id}?page=${page + 1}`}>
Next
</Link>
) : (
<span>Next</span>
)}
</Button>
</div>
)}
</div>
);
}

11
app/dracin/layout.tsx Normal file
View File

@@ -0,0 +1,11 @@
export default function DracinLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
</>
);
}

5
app/dracin/loading.tsx Normal file
View File

@@ -0,0 +1,5 @@
import LoadingSplash from "@/components/LoadingSplash";
export default function Loading() {
return <LoadingSplash />;
}

58
app/dracin/page.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { getDracinHome } from '@/lib/dramabox';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import DracinCard from '@/components/DracinCard';
interface DracinPageProps {
searchParams: Promise<{ page?: string }>;
}
export default async function DracinPage({ searchParams }: DracinPageProps) {
const params = await searchParams;
const page = Number(params.page) || 1;
const size = 24;
const dracinList = await getDracinHome(page, size);
return (
<div className="pt-24 px-4 md:px-16 min-h-screen pb-10">
<h1 className="text-2xl font-bold text-white mb-2">Chinese Drama</h1>
<div className="flex items-center gap-4 text-sm md:text-base text-gray-400 mb-6">
<Link href="/dracin/vip" className="hover:text-red-600 transition">VIP</Link>
<span className="w-1 h-1 bg-gray-600 rounded-full"></span>
<Link href="/dracin/recommend" className="hover:text-red-600 transition">Recommend</Link>
<span className="w-1 h-1 bg-gray-600 rounded-full"></span>
<Link href="/dracin/categories" className="hover:text-red-600 transition">Categories</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-8">
{dracinList.map((item) => (
<DracinCard key={item.id} item={item} />
))}
</div>
{dracinList.length === 0 && (
<div className="text-gray-500 text-center py-10">No dramas found.</div>
)}
{/* Pagination Controls */}
<div className="flex justify-center items-center gap-4 mt-8">
<Link href={page > 1 ? `/dracin?page=${page - 1}` : '#'}>
<Button variant="outline" disabled={page <= 1} className="flex items-center gap-1 bg-black/50 text-white border-gray-700 hover:bg-gray-800">
Previous
</Button>
</Link>
<span className="text-white font-medium">Page {page}</span>
<Link href={dracinList.length === size ? `/dracin?page=${page + 1}` : '#'}>
<Button
variant="outline"
disabled={dracinList.length < size}
className="flex items-center gap-1 bg-black/50 text-white border-gray-700 hover:bg-gray-800"
>
Next
</Button>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { getDracinRecommend } from '@/lib/dramabox';
import DracinCard from '@/components/DracinCard';
export default async function DracinRecommendPage() {
const items = await getDracinRecommend(1, 40); // Fetch a good amount
return (
<div className="pt-24 px-4 md:px-16 min-h-screen">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2">Just For You</h1>
<p className="text-gray-400">Chinese dramas for you.</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{items.length > 0 ? (
items.map((item, idx) => (
<DracinCard key={`${item.id}-${idx}`} item={item} />
))
) : (
<div className="col-span-full text-center text-gray-500 py-20">
No recommendations found.
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { searchDracin } from '@/lib/dramabox';
import DracinCard from '@/components/DracinCard';
interface SearchPageProps {
searchParams: Promise<{ q: string }>;
}
export default async function DracinSearchPage({ searchParams }: SearchPageProps) {
const { q } = await searchParams;
const items = q ? await searchDracin(q, 1, 40) : [];
return (
<div className="pt-24 px-4 md:px-16 min-h-screen">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2">Search Results</h1>
<p className="text-gray-400">
{q ? `Results for "${q}"` : 'Enter a keyword to search'}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{items.length > 0 ? (
items.map((item, idx) => (
<DracinCard key={`${item.id}-${idx}`} item={item} />
))
) : (
<div className="col-span-full text-center text-gray-500 py-20">
{q ? 'No results found.' : 'Waiting for input...'}
</div>
)}
</div>
</div>
);
}

27
app/dracin/vip/page.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { getDracinVip } from '@/lib/dramabox';
import DracinCard from '@/components/DracinCard';
export default async function DracinVipPage() {
const items = await getDracinVip(1, 40);
return (
<div className="pt-24 px-4 md:px-16 min-h-screen">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#f5c518] mb-2">Premium Dramas</h1>
<p className="text-gray-400">Exclusive premium content.</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{items.length > 0 ? (
items.map((item, idx) => (
<DracinCard key={`${item.id}-${idx}`} item={item} />
))
) : (
<div className="col-span-full text-center text-gray-500 py-20">
No VIP items found.
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { getDracinStream, getDracinDetail } from '@/lib/dramabox';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { List, ArrowRight, ArrowLeft } from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import VideoPlayer from '@/components/VideoPlayer';
import { DracinPlayer } from '@/components/DracinPlayer';
interface DracinWatchPageProps {
params: Promise<{ id: string; episode: string }>;
}
export default async function DracinWatchPage({ params }: DracinWatchPageProps) {
const { id, episode } = await params;
const episodeNum = parseInt(episode);
// Auth Check
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
// Fetch stream URL
const streamUrl = await getDracinStream(id, episodeNum);
// Fetch detail for title and navigation context
const { drama } = await getDracinDetail(id);
if (!streamUrl) {
return (
<div className="pt-24 px-4 text-center text-white">
<h1 className="text-2xl font-bold mb-4">Error</h1>
<p className="mb-8">Could not load stream for Episode {episode}. It might be a premium/VIP episode or API error.</p>
<Link href={`/dracin/${id}`}>
<Button variant="outline">Back to Detail</Button>
</Link>
</div>
);
}
return (
<div className="pt-20 min-h-screen bg-black flex flex-col items-center">
{/* Player Container */}
<div className="w-full max-w-5xl aspect-video bg-black relative shadow-2xl">
<DracinPlayer
id={id}
episode={episodeNum}
title={drama?.name || 'Unknown Drama'}
poster={drama?.cover || ''}
streamUrl={streamUrl}
/>
</div>
{/* Info & Navigation */}
<div className="w-full max-w-5xl px-4 py-6 text-white">
<div className="mb-6">
<h1 className="text-xl md:text-2xl font-bold mb-6 text-center">
{drama?.name || 'Unknown Drama'} - Episode {episodeNum + 1}
</h1>
<div className="grid grid-cols-3 items-center">
{/* Left: Previous */}
<div className="flex justify-start">
{episodeNum > 0 ? (
<Link href={`/dracin/watch/${id}/${episodeNum - 1}`}>
<Button variant="outline" className="border-gray-700">
<ArrowLeft className="w-4 h-4 mr-2" />
Previous
</Button>
</Link>
) : (
<div />
)}
</div>
{/* Center: Back */}
<div className="flex justify-center">
<Link href={`/dracin/${id}`}>
<Button variant="ghost" className="bg-[#141414] hover:bg-[#b00710] text-white">
<List className="w-4 h-4 mr-2" />
List
</Button>
</Link>
</div>
{/* Right: Next */}
<div className="flex justify-end">
<Link href={`/dracin/watch/${id}/${episodeNum + 1}`}>
<Button className="bg-[#E50914] hover:bg-[#b00710] text-white">
Next
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import LoadingSplash from "@/components/LoadingSplash";
export default function Loading() {
return <LoadingSplash />;
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

152
app/globals.css Normal file
View File

@@ -0,0 +1,152 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@source "../app";
@source "../components";
@source "../lib";
:root {
--primary: oklch(0.205 0 0);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
}
body {
overflow-x: hidden;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
}

102
app/history/page.tsx Normal file
View File

@@ -0,0 +1,102 @@
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { ChevronLeft, Play } from "lucide-react";
export default async function HistoryPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: history, error } = await supabase
.from("history")
.select("*")
.eq("user_id", user.id)
.order("updated_at", { ascending: false });
if (error) {
console.error("Error fetching history:", error);
return <div className="min-h-screen pt-24 text-white text-center">Error loading history</div>;
}
return (
<div className="min-h-screen bg-[#050B14] text-white pt-24 pb-12 px-4 md:px-16">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<Link href="/profile" className="p-2 bg-gray-800 rounded-full hover:bg-gray-700 transition">
<ChevronLeft className="w-6 h-6 text-white" />
</Link>
<h1 className="text-3xl font-bold">Watch History</h1>
</div>
{history.length === 0 ? (
<div className="text-center text-gray-400 py-20">
<p className="text-xl">No history yet.</p>
<Link href="/" className="text-red-500 hover:underline mt-4 inline-block">Start watching</Link>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{history.map((item) => {
const progress = item.duration > 0 ? (item.last_position / item.duration) * 100 : 0;
const href = item.type === 'dracin'
? `/dracin/watch/${item.subject_id}/${item.episode || 0}`
: `/movie/${item.subject_id}?autoplay=true`;
return (
<Link
key={item.id}
href={href}
className="group bg-[#141414] rounded-lg overflow-hidden border border-gray-800 transition hover:border-red-600"
>
<div className="relative aspect-video">
{item.poster ? (
<Image
src={item.poster}
alt={item.title}
fill
className="object-cover opacity-80 group-hover:opacity-100 transition-opacity"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-800 text-gray-500">
No Image
</div>
)}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="bg-red-600 rounded-full p-2">
<Play className="w-6 h-6 fill-white text-white" />
</div>
</div>
{/* Progress Bar */}
<div className="absolute bottom-0 left-0 w-full h-1 bg-gray-800">
<div
className="h-full bg-red-600"
style={{ width: `${Math.min(100, progress)}%` }}
/>
</div>
</div>
<div className="p-4">
<h3 className="font-bold text-lg line-clamp-1 mb-1">{item.title}</h3>
<div className="flex items-center justify-between text-sm text-gray-400">
<span>
{item.type === 'series' && `S${item.season} E${item.episode}`}
{item.type === 'dracin' && `Episode ${Number(item.episode) + 1}`}
{item.type === 'movie' && 'Movie'}
</span>
<span>{Math.floor(item.last_position / 60)}m left</span>
</div>
</div>
</Link>
);
})}
</div>
)}
</div>
</div>
);
}

26
app/layout.tsx Normal file
View File

@@ -0,0 +1,26 @@
import type { Metadata } from 'next';
import './globals.css';
import Navbar from '@/components/Navbar';
import BottomNav from '@/components/BottomNav';
export const metadata: Metadata = {
title: 'Cineprime - Watch Movies & Series',
description: 'Unlimited movies, TV shows, and more.',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark overflow-x-hidden">
<body className="antialiased bg-[#141414] text-white pb-16">
<Navbar />
{children}
<BottomNav />
</body>
</html>
);
}

5
app/loading.tsx Normal file
View File

@@ -0,0 +1,5 @@
import LoadingSplash from "@/components/LoadingSplash";
export default function Loading() {
return <LoadingSplash />;
}

136
app/login/page.tsx Normal file
View File

@@ -0,0 +1,136 @@
'use client';
import { useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { X } from 'lucide-react';
import Link from 'next/link';
import HCaptcha from '@hcaptcha/react-hcaptcha';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const captchaRef = useRef<HCaptcha>(null);
const router = useRouter();
const supabase = createClient();
const handleGoogleLogin = async () => {
setLoading(true);
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${location.origin}/auth/callback`,
},
});
if (error) {
setError(error.message);
setLoading(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!captchaToken) {
setError('Please complete the captcha');
return;
}
setLoading(true);
setError('');
const { error } = await supabase.auth.signInWithPassword({
email,
password,
options: {
captchaToken,
},
});
if (error) {
setError(error.message);
setLoading(false);
setCaptchaToken(null);
captchaRef.current?.resetCaptcha();
} else {
router.push('/profile');
router.refresh();
}
};
return (
<div
className="flex h-screen w-screen overflow-hidden items-center justify-center p-4 relative bg-cover bg-center"
style={{ backgroundImage: "url('/login-bg.png')" }}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<Card className="w-full max-w-sm bg-transparent border-none text-white relative shadow-none z-10">
<button
onClick={() => router.push('/')}
className="absolute right-0 -top-12 text-gray-300 hover:text-white transition"
>
<X className="w-8 h-8" />
</button>
<CardHeader className="px-0">
<CardTitle className="text-3xl font-bold text-white text-center mb-2">Start streaming now with Cineprime</CardTitle>
</CardHeader>
<CardContent className="px-0">
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-gray-300">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-black/40 border-gray-600 text-white placeholder:text-gray-500 h-12"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-black/40 border-gray-600 text-white h-12"
required
/>
</div>
{error && (
<div className="text-red-500 text-sm font-medium">{error}</div>
)}
<div className="flex justify-center transition-all duration-300">
<HCaptcha
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || ""}
onVerify={(token) => setCaptchaToken(token)}
onExpire={() => setCaptchaToken(null)}
ref={captchaRef}
theme="dark"
/>
</div>
<Button type="submit" className="w-full h-12 bg-gradient-to-r from-red-600 to-red-800 hover:from-red-700 hover:to-red-900 text-white font-bold text-lg shadow-lg shadow-red-900/20" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className="mt-8 text-center text-sm text-gray-400">
Don't have an account? <Link href="/register" className="text-white hover:underline font-bold">Sign Up</Link>
</div>
</CardContent>
</Card>
</div>
);
}

28
app/movie/[id]/page.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { getMovieDetail } from '@/lib/api';
import MovieDetailView from '@/components/MovieDetailView';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function MoviePage({ params }: PageProps) {
const { id } = await params;
if (!id || id === '[object Object]') {
return <div className="min-h-screen bg-[#141414] text-white flex items-center justify-center">Invalid Movie ID</div>;
}
try {
const detail = await getMovieDetail(id);
return <MovieDetailView detail={detail} />;
} catch (e) {
// Silently handle error for invalid IDs
return (
<div className="min-h-screen bg-[#141414] text-white flex flex-col items-center justify-center gap-4">
<h1 className="text-2xl font-bold">Movie Not Found</h1>
<p className="text-gray-400">The content you are looking for does not exist or has been removed.</p>
<a href="/" className="bg-red-600 px-6 py-2 rounded-full hover:bg-red-700 transition">Go Home</a>
</div>
);
}
}

136
app/page.tsx Normal file
View File

@@ -0,0 +1,136 @@
import HeroSlider from '@/components/HeroSlider';
import MovieRow from '@/components/MovieRow';
import { getHomepageData, getMovieDetail, HomeSection, Subject } from '@/lib/api';
import { getDracinHome, getDracinDetail, DramaboxItem } from '@/lib/dramabox';
export default async function Home() {
console.log("Fetching homepage data...");
let data;
let dracinData: DramaboxItem[] = [];
try {
// Fetch both datasets in parallel
const [homeData, dracinRes] = await Promise.all([
getHomepageData(),
getDracinHome(1, 10) // Fetch top 10 Dracin items
]);
data = homeData;
dracinData = dracinRes;
} catch (e) {
console.error("Error generating homepage:", e);
return (
<div className="pt-32 text-center text-red-500 px-4">
<h2 className="text-xl font-bold mb-2">Error loading data</h2>
<p className="text-sm font-mono bg-black/50 p-4 rounded inline-block max-w-full overflow-auto">
{e instanceof Error ? e.message : String(e)}
</p>
</div>
);
}
// Use operatingList if available, otherwise fallback to homeList
let contentList = data.operatingList || data.homeList;
if (!contentList) {
contentList = [];
}
// Map Dracin Data to Subject format
if (dracinData.length > 0) {
const dracinSection: HomeSection = {
type: 'SUBJECTS_MOVIE', // Reuse existing type for rendering
title: 'Latest Dracin', // Title for the new row
subjects: dracinData.map(d => ({
subjectId: d.id.toString(),
subjectType: 3, // Custom type
title: d.name,
description: d.introduction,
releaseDate: "",
genre: d.tags.join(", "),
cover: { url: d.cover, width: 300, height: 450 },
image: { url: d.cover, width: 300, height: 450 },
countryName: "China",
imdbRatingValue: "N/A",
detailPath: "",
isDracin: true
}))
};
// Insert Dracin section after the first movie section (usually index 1)
if (contentList.length > 1) {
contentList.splice(1, 0, dracinSection);
} else {
contentList.push(dracinSection);
}
}
if (contentList.length === 0) {
return <div className="pt-32 text-white text-center">No Content Available</div>;
}
// Find Banner section
const bannerSection = contentList.find((s) => s.type === 'BANNER');
// Get all items for the slider
let bannerItems = bannerSection?.banner?.items || bannerSection?.subjects || [];
// Enrich banner items with descriptions if missing
if (bannerItems.length > 0) {
bannerItems = await Promise.all(bannerItems.map(async (item: any) => {
const hasDesc = item.description && item.description !== "No description available.";
const hasSubjectDesc = item.subject?.description && item.subject.description !== "No description available.";
if (hasDesc || hasSubjectDesc) return item;
const id = item.subjectId || item.id || item.subject?.subjectId;
if (!id) return item;
try {
// Try fetching as a movie first (most common and covers the long numeric IDs we saw)
try {
const detail = await getMovieDetail(id);
if (detail && detail.subject && detail.subject.description) {
return { ...item, description: detail.subject.description };
}
} catch (movieError) {
// Ignore error and try Dracin
}
// If movie fetch failed or didn't return a description, try Dracin
const { drama } = await getDracinDetail(id);
if (drama && drama.introduction) {
return { ...item, description: drama.introduction };
}
} catch (e) {
// Log as warning to differentiate from critical errors
// console.warn(`Failed to enrich banner item ${id}:`, e instanceof Error ? e.message : e);
}
return item;
}));
}
return (
<main className="relative pb-16">
{/* Hero Section */}
{bannerItems.length > 0 && <HeroSlider items={bannerItems} />}
{/* Movie Rows */}
<div className="flex flex-col gap-2 -mt-16 md:-mt-32 relative z-10 pl-0 md:pl-0">
{contentList.map((section, index) => {
if (section.type === 'SUBJECTS_MOVIE' && section.subjects && section.subjects.length > 0) {
const isDracinSection = section.title === 'Latest Dracin';
return (
<MovieRow
key={`${section.title}-${index}`}
title={section.title}
movies={section.subjects}
headerContent={undefined}
/>
);
}
return null;
})}
</div>
</main>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ArrowLeft, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
export default function AccountSettingsPage() {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [fullName, setFullName] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const router = useRouter();
const supabase = createClient();
useEffect(() => {
const getUser = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/login');
} else {
setUser(user);
setFullName(user.user_metadata?.full_name || '');
}
setLoading(false);
};
getUser();
}, [router, supabase]);
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setMessage(null);
try {
const updates: any = {
data: { full_name: fullName }
};
if (password) {
updates.password = password;
}
const { error } = await supabase.auth.updateUser(updates);
if (error) {
throw error;
}
setMessage({ type: 'success', text: 'Profile updated successfully' });
if (password) setPassword(''); // Clear password field on success
} catch (error: any) {
setMessage({ type: 'error', text: error.message });
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="min-h-screen bg-[#050B14] flex items-center justify-center text-white">Loading...</div>;
}
return (
<div className="min-h-screen bg-[#050B14] text-white pt-24 pb-12 font-sans">
<div className="max-w-2xl mx-auto px-4">
<Link href="/profile" className="inline-flex items-center text-gray-400 hover:text-white mb-6 transition">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Profile
</Link>
<Card className="bg-[#1f1f1f] border-gray-800 text-white">
<CardHeader>
<CardTitle className="text-2xl font-bold">Account Settings</CardTitle>
<CardDescription className="text-gray-400">
Update your personal information and security.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpdateProfile} className="space-y-6">
{/* Email (Read Only) */}
<div className="space-y-2">
<Label htmlFor="email" className="text-gray-300">Email Address</Label>
<Input
id="email"
value={user?.email || ''}
disabled
className="bg-[#141414] border-gray-700 text-gray-500 cursor-not-allowed"
/>
</div>
{/* Full Name */}
<div className="space-y-2">
<Label htmlFor="fullName" className="text-gray-300">Display Name</Label>
<Input
id="fullName"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="bg-[#141414] border-gray-700 text-white focus:border-red-600 focus:ring-red-600"
placeholder="Enter your name"
/>
</div>
{/* Password */}
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">New Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-[#141414] border-gray-700 text-white focus:border-red-600 focus:ring-red-600"
placeholder="Leave blank to keep current password"
/>
<p className="text-xs text-gray-500">Only enter a new password if you want to change it.</p>
</div>
{/* Status Message */}
{message && (
<div className={`p-3 rounded flex items-center gap-2 ${message.type === 'success' ? 'bg-green-900/20 text-green-400 border border-green-900' : 'bg-red-900/20 text-red-400 border border-red-900'}`}>
{message.type === 'success' ? <CheckCircle className="w-5 h-5" /> : <AlertCircle className="w-5 h-5" />}
<span>{message.text}</span>
</div>
)}
{/* Submit Button */}
<Button
type="submit"
disabled={saving}
className="w-full bg-red-600 hover:bg-red-700 text-white font-bold"
>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { ChevronLeft } from "lucide-react";
export default async function BookmarksPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: bookmarks, error } = await supabase
.from("bookmarks")
.select("*")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
if (error) {
console.error("Error fetching bookmarks:", error);
return <div className="min-h-screen pt-24 text-white text-center">Error loading bookmarks</div>;
}
return (
<div className="min-h-screen bg-[#050B14] text-white pt-24 pb-12 px-4 md:px-16">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<Link href="/profile" className="p-2 bg-gray-800 rounded-full hover:bg-gray-700 transition">
<ChevronLeft className="w-6 h-6 text-white" />
</Link>
<h1 className="text-3xl font-bold">My Bookmarks</h1>
</div>
{bookmarks.length === 0 ? (
<div className="text-center text-gray-400 py-20">
<p className="text-xl">No bookmarks yet.</p>
<Link href="/" className="text-red-500 hover:underline mt-4 inline-block">Explore movies</Link>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{bookmarks.map((item) => (
<Link
key={item.id}
href={item.type === 'movie' ? `/movie/${item.subject_id}` : `/dracin/${item.subject_id}`}
className="group relative aspect-[2/3] rounded-lg overflow-hidden border border-gray-800 bg-gray-900 transition hover:scale-105"
>
{item.poster ? (
<Image
src={item.poster}
alt={item.title}
fill
className="object-cover"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-800 text-gray-500">
No Image
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-4">
<span className="text-white font-medium text-sm line-clamp-2">{item.title}</span>
</div>
</Link>
))}
</div>
)}
</div>
</div>
);
}

112
app/profile/page.tsx Normal file
View File

@@ -0,0 +1,112 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { createClient } from '@/lib/supabase/client';
import { Switch } from '@/components/ui/switch';
import { User, Bookmark, History, FileText, ChevronRight, Crown, Shield } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
export default function ProfilePage() {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const [supabase] = useState(() => createClient());
useEffect(() => {
const getUser = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/login');
} else {
setUser(user);
}
setLoading(false);
};
getUser();
}, [router, supabase]);
const handleSignOut = async () => {
await supabase.auth.signOut();
router.push('/login');
router.refresh();
};
if (loading) {
return <div className="min-h-screen bg-[#050B14] flex items-center justify-center text-white">Loading...</div>;
}
if (!user) return null;
const menuItems = [
{ icon: User, label: "Account settings", href: "/profile/account" },
{ icon: Bookmark, label: "Bookmark", href: "/profile/bookmarks" },
{ icon: History, label: "History", href: "/history" },
{ icon: Shield, label: "Contact support", href: "#" },
{ icon: FileText, label: "Terms & condition", href: "#" },
];
return (
<div className="min-h-screen bg-[#050B14] text-white pt-24 pb-24 relative overflow-hidden font-sans">
{/* Background Effect */}
<div className="absolute top-0 left-0 w-full h-[50vh] bg-gradient-to-b from-red-900/20 to-[#050B14] pointer-events-none" />
<div className="max-w-md mx-auto px-6 relative z-10 flex flex-col items-center">
{/* Profile Header */}
<div className="flex flex-col items-center mb-10">
<div className="relative mb-4">
<Avatar className="w-24 h-24 border-2 border-gray-700">
<AvatarImage src={user.user_metadata?.avatar_url || "https://github.com/shadcn.png"} />
<AvatarFallback className="bg-red-600 text-2xl font-bold">
{user.email?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Edit Icon Badge (Optional) */}
{/* <div className="absolute bottom-0 right-0 bg-gray-800 p-1 rounded-full border border-black">
<Camera className="w-4 h-4 text-gray-400" />
</div> */}
</div>
<h1 className="text-2xl font-bold mb-1">{user.user_metadata?.full_name || "Cineprime User"}</h1>
<p className="text-gray-400 text-sm">{user.email}</p>
</div>
{/* Menu List */}
<div className="w-full space-y-2 mb-8">
{menuItems.map((item, index) => (
<Link key={index} href={item.href} className="flex items-center justify-between py-4 cursor-pointer hover:bg-white/5 rounded-lg px-2 transition">
<div className="flex items-center gap-4">
<item.icon className="w-6 h-6 text-red-500" />
<span className="text-base font-medium text-gray-200">{item.label}</span>
</div>
<div className="flex items-center gap-2">
<ChevronRight className="w-5 h-5 text-gray-500" />
</div>
</Link>
))}
</div>
{/* Premium Button */}
<Button
className="w-full h-14 bg-gradient-to-r from-red-600 to-red-900 hover:from-red-700 hover:to-red-950 text-white text-lg font-bold rounded-xl shadow-lg shadow-red-900/40 flex items-center justify-center gap-2 mb-6"
>
<Crown className="w-6 h-6 fill-white" />
Join premium
</Button>
{/* Sign Out (Custom) */}
<button
onClick={handleSignOut}
className="text-gray-500 font-medium text-sm hover:text-white hover:underline transition"
>
Sign Out
</button>
</div>
</div>
);
}

33
app/rank/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { getRank, Subject } from '@/lib/api';
import MovieRow from '@/components/MovieRow';
export const revalidate = 3600;
export default async function RankPage() {
let rankData: { movie: Subject[]; tv: Subject[] } = { movie: [], tv: [] };
try {
rankData = await getRank();
} catch (e) {
console.error("Failed to fetch rank:", e);
}
return (
<div className="min-h-screen pt-24 pb-16" >
<h1 className="text-3xl font-bold text-white mb-8 px-4 md:px-16">Top Ranked</h1>
<div className="flex flex-col gap-8">
{rankData.movie && rankData.movie.length > 0 && (
<MovieRow title="Top Rated Movies" movies={rankData.movie} />
)}
{rankData.tv && rankData.tv.length > 0 && (
<MovieRow title="Top Rated TV Shows" movies={rankData.tv} />
)}
{(!rankData.movie || rankData.movie.length === 0) && (!rankData.tv || rankData.tv.length === 0) && (
<div className="text-gray-500 px-4 md:px-16">No ranking data available.</div>
)}
</div>
</div>
);
}

158
app/register/page.tsx Normal file
View File

@@ -0,0 +1,158 @@
'use client';
import { useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { X } from 'lucide-react';
import Link from 'next/link';
import HCaptcha from '@hcaptcha/react-hcaptcha';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const captchaRef = useRef<HCaptcha>(null);
const router = useRouter();
const supabase = createClient();
const handleGoogleSignup = async () => {
setLoading(true);
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${location.origin}/auth/callback`,
},
});
if (error) {
setError(error.message);
setLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
if (!captchaToken) {
setError('Please complete the captcha');
return;
}
setLoading(true);
setError('');
const { error } = await supabase.auth.signUp({
email,
password,
options: {
captchaToken,
},
});
if (error) {
setError(error.message);
setLoading(false);
setCaptchaToken(null);
captchaRef.current?.resetCaptcha();
} else {
setSuccess(true);
setLoading(false);
}
};
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-black p-4">
<Card className="w-full max-w-sm bg-[#141414] border-gray-800 text-white">
<CardHeader>
<CardTitle className="text-2xl text-primary text-center">Check Your Email</CardTitle>
<CardDescription className="text-center">
We have sent a verification link to <strong>{email}</strong>.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Button onClick={() => router.push('/login')} className="bg-red-600 hover:bg-red-700">
Back to Login
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div
className="flex h-screen w-screen overflow-hidden items-center justify-center p-4 relative bg-cover bg-center"
style={{ backgroundImage: "url('/login-bg.png')" }}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<Card className="w-full max-w-sm bg-transparent border-none text-white relative shadow-none z-10">
<button
onClick={() => router.push('/')}
className="absolute right-0 -top-12 text-gray-300 hover:text-white transition"
>
<X className="w-8 h-8" />
</button>
<CardHeader className="px-0">
<CardTitle className="text-3xl font-bold text-white text-center mb-2">Join Cineprime Today</CardTitle>
</CardHeader>
<CardContent className="px-0">
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-gray-300">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-black/40 border-gray-600 text-white placeholder:text-gray-500 h-12"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-black/40 border-gray-600 text-white h-12"
required
minLength={6}
/>
</div>
{error && (
<div className="text-red-500 text-sm font-medium">{error}</div>
)}
<div className="flex justify-center transition-all duration-300">
<HCaptcha
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || ""}
onVerify={(token) => setCaptchaToken(token)}
onExpire={() => setCaptchaToken(null)}
ref={captchaRef}
theme="dark"
/>
</div>
<Button type="submit" className="w-full h-12 bg-gradient-to-r from-red-600 to-red-800 hover:from-red-700 hover:to-red-900 text-white font-bold text-lg shadow-lg shadow-red-900/20" disabled={loading}>
{loading ? 'Creating Account...' : 'Sign Up'}
</Button>
</form>
<div className="mt-8 text-center text-sm text-gray-400">
Already have an account? <Link href="/login" className="text-white hover:underline font-bold">Log In</Link>
</div>
</CardContent>
</Card>
</div>
);
}

11
app/search/actions.ts Normal file
View File

@@ -0,0 +1,11 @@
'use server';
import { searchDracin } from '@/lib/dramabox';
import { Subject } from '@/lib/api';
import { DramaboxItem } from '@/lib/dramabox';
export async function searchDracinAction(query: string, page: number = 1) {
const dracinData = await searchDracin(query, page, 20);
// Return raw data, mapping will happen in client or here.
return dracinData;
}

214
app/search/page.tsx Normal file
View File

@@ -0,0 +1,214 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Search as SearchIcon } from 'lucide-react';
import { getSearch, Subject } from '@/lib/api';
import { searchDracinAction } from './actions';
import { DramaboxItem } from '@/lib/dramabox';
import MovieCard from '@/components/MovieCard';
export default function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Subject[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [searchType, setSearchType] = useState<'movie' | 'dracin'>('movie');
const abortControllerRef = useRef<AbortController | null>(null);
const performSearch = async (searchQuery: string, pageNum: number, append: boolean = false, typeOverride?: 'movie' | 'dracin') => {
const currentType = typeOverride || searchType;
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
if (!append) setResults([]);
try {
let movieList: Subject[] = [];
let dracinList: Subject[] = [];
let moviesHasMore = false;
let dracinHasMore = false;
if (currentType === 'movie') {
const moviesData = await getSearch(searchQuery, pageNum, controller.signal);
if (controller.signal.aborted) return;
movieList = Array.isArray(moviesData) ? moviesData : ((moviesData as any).items || (moviesData as any).list || (moviesData as any).subjects || (moviesData as any).searchList || []);
moviesHasMore = (moviesData as any).pager?.hasMore || false;
} else {
const dracinData = await searchDracinAction(searchQuery, pageNum);
if (controller.signal.aborted) return;
dracinList = (dracinData as DramaboxItem[]).map(item => ({
subjectId: item.id.toString(),
title: item.name,
cover: { url: item.cover, width: 0, height: 0 },
image: { url: item.cover, width: 0, height: 0 },
rate: 'N/A',
isDracin: true,
subjectType: 0,
description: item.introduction || '',
releaseDate: '',
genre: item.tags ? item.tags.join(', ') : '',
originalTitle: item.name,
viewCount: item.playCount || 0,
rec: false,
countryName: 'CN',
imdbRatingValue: 'N/A',
detailPath: `/dracin/${item.id}`
}));
dracinHasMore = Array.isArray(dracinData) && dracinData.length >= 20;
}
// Unified list (though now they are separate, deduplication is still good practice)
const combined = [...movieList, ...dracinList];
const uniqueList = Array.from(new Map(combined.map((item: Subject) => [item.subjectId, item])).values()) as Subject[];
if (append) {
setResults(prev => {
const newCombined = [...prev, ...uniqueList];
return Array.from(new Map(newCombined.map((item: Subject) => [item.subjectId, item])).values()) as Subject[];
});
} else {
setResults(uniqueList);
}
setHasMore(currentType === 'movie' ? moviesHasMore : dracinHasMore);
} catch (error: any) {
if (error.name === 'AbortError') return;
console.error(error);
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
};
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
if (query.trim()) {
setPage(1);
performSearch(query, 1, false, searchType);
} else {
setResults([]);
setHasMore(false);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}
}, 800);
return () => {
clearTimeout(delayDebounceFn);
};
}, [query, searchType]);
const handleTypeChange = (type: 'movie' | 'dracin') => {
if (type === searchType) return;
setSearchType(type);
setPage(1);
setResults([]);
setHasMore(false);
if (query.trim()) {
performSearch(query, 1, false, type);
}
};
const handleLoadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
performSearch(query, nextPage, true);
};
return (
<div className="min-h-screen bg-[#141414]">
{/* Fixed Search Type & Input Header */}
<div className="fixed top-[60px] left-0 w-full z-40 bg-[#141414]/95 backdrop-blur-md border-b border-white/5 px-4 md:px-16 pt-4 pb-0 transition-all">
<div className="max-w-4xl mx-auto space-y-4 pb-4">
{/* Search Type Selector */}
<div className="flex justify-center gap-2">
<button
onClick={() => handleTypeChange('movie')}
className={`flex-1 md:flex-none md:min-w-[120px] px-6 py-2 rounded-full font-bold transition-all text-sm md:text-base border ${searchType === 'movie'
? 'bg-red-600 text-white border-red-600 shadow-lg shadow-red-900/40'
: 'bg-[#1f1f1f] text-gray-400 border-gray-800 hover:bg-[#252525] hover:text-white'
}`}
>
Film
</button>
<button
onClick={() => handleTypeChange('dracin')}
className={`flex-1 md:flex-none md:min-w-[120px] px-6 py-2 rounded-full font-bold transition-all text-sm md:text-base border ${searchType === 'dracin'
? 'bg-red-600 text-white border-red-600 shadow-lg shadow-red-900/40'
: 'bg-[#1f1f1f] text-gray-400 border-gray-800 hover:bg-[#252525] hover:text-white'
}`}
>
Dracin
</button>
</div>
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${searchType === 'movie' ? 'movies' : 'dramas'}...`}
className="w-full bg-[#1f1f1f] border border-gray-700 text-white px-12 py-3 rounded-full text-lg focus:outline-none focus:border-red-600 transition-colors shadow-lg"
autoFocus
/>
<SearchIcon className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
</div>
</div>
</div>
{/* Results Content - Padded to start below fixed headers */}
<div className="pt-56 px-4 md:px-16 pb-20">
{loading && page === 1 && (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<div className="w-12 h-12 border-4 border-red-600 border-t-transparent rounded-full animate-spin"></div>
<div className="text-gray-500 animate-pulse font-medium">Searching {searchType === 'movie' ? 'Movies' : 'Dramas'}...</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{results.map((item) => (
<MovieCard key={`${item.isDracin ? 'd-' : 'm-'}${item.subjectId}`} movie={item} />
))}
</div>
{!loading && hasMore && results.length > 0 && query && (
<div className="flex justify-center mt-12">
<button
onClick={handleLoadMore}
className="bg-red-600 hover:bg-red-700 text-white px-8 py-3 rounded-full font-bold transition-colors shadow-lg active:scale-95 transform"
>
Load More
</button>
</div>
)}
{loading && page > 1 && (
<div className="text-center text-gray-500 mt-8 animate-pulse">Loading more {searchType === 'movie' ? 'movies' : 'dramas'}...</div>
)}
{!loading && query && results.length === 0 && (
<div className="text-center text-gray-400 mt-20">
<div className="text-6xl mb-4">🔍</div>
<p className="text-xl font-bold text-white">No results found</p>
<p className="text-sm mt-2 text-gray-500">
We couldn't find any {searchType === 'movie' ? 'movies' : 'dramas'} matching "{query}".
</p>
<p className="text-sm text-gray-500">Try adjusting your search or switching categories.</p>
</div>
)}
</div>
</div>
);
}

97
app/trending/page.tsx Normal file
View File

@@ -0,0 +1,97 @@
import { getTrending, Subject, TrendingResponse } from '@/lib/api';
import { getDracinRecommend, DramaboxItem } from '@/lib/dramabox';
import MovieCard from '@/components/MovieCard';
import Link from 'next/link';
export const revalidate = 3600;
export default async function TrendingPage({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
const { page: pageParam } = await searchParams;
const page = parseInt(pageParam || "1", 10);
let trendingData: TrendingResponse = { subjectList: [], pager: { hasMore: false, nextPage: "1", page: "0", perPage: 0, totalCount: 0 } };
let dracinData: DramaboxItem[] = [];
try {
const [trendingRes, dracinRes] = await Promise.all([
getTrending(page),
getDracinRecommend(page, 20)
]);
trendingData = trendingRes;
dracinData = dracinRes;
} catch (e) {
console.error("Failed to fetch trending:", e);
}
const { subjectList, pager } = trendingData;
// Map Dracin Data to Subject
const dracinSubjects: Subject[] = dracinData.map(d => ({
subjectId: d.id.toString(),
subjectType: 3,
title: d.name,
description: d.introduction,
releaseDate: "",
genre: d.tags.join(", "),
cover: { url: d.cover, width: 300, height: 450 },
image: { url: d.cover, width: 300, height: 450 },
countryName: "China",
imdbRatingValue: "N/A",
detailPath: `/dracin/${d.id}`,
isDracin: true
}));
// Interleave or merge lists
// Simple merge: [...subjectList, ...dracinSubjects]
// Or interleave for better diversity
const combinedList: Subject[] = [];
const maxLength = Math.max(subjectList.length, dracinSubjects.length);
for (let i = 0; i < maxLength; i++) {
if (i < subjectList.length) combinedList.push(subjectList[i]);
if (i < dracinSubjects.length) combinedList.push(dracinSubjects[i]);
}
return (
<div className="min-h-screen pt-24 px-4 md:px-16" >
<h1 className="text-3xl font-bold text-white mb-8">Trending Now</h1>
{combinedList.length === 0 ? (
<div className="text-gray-500">No trending content found.</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 mb-12">
{combinedList.map((movie) => (
<MovieCard key={`${movie.isDracin ? 'd' : 'm'}-${movie.subjectId}`} movie={movie} />
))}
</div>
{/* Pagination */}
<div className="flex justify-center gap-4 pb-12">
{page > 1 && (
<Link
href={`/trending?page=${page - 1}`}
className="px-6 py-2 bg-gray-800 text-white rounded hover:bg-gray-700 transition"
>
Previous
</Link>
)}
<span className="px-4 py-2 text-gray-400 flex items-center">
Page {page}
</span>
{/* Use pager.hasMore from main API, effectively limiting Dracin nav to main API limits unless we want infinite dracin pages */}
{pager.hasMore && (
<Link
href={`/trending?page=${page + 1}`}
className="px-6 py-2 bg-primary text-white rounded hover:bg-red-700 transition"
>
Next
</Link>
)}
</div>
</>
)}
</div>
);
}