first commit
This commit is contained in:
25
app/admin/layout.tsx
Normal file
25
app/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
app/admin/movies/[id]/EpisodeSelector.tsx
Normal file
100
app/admin/movies/[id]/EpisodeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
app/admin/movies/[id]/page.tsx
Normal file
222
app/admin/movies/[id]/page.tsx
Normal 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
113
app/admin/movies/page.tsx
Normal 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
93
app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
app/admin/roles/RoleSelect.tsx
Normal file
56
app/admin/roles/RoleSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/admin/roles/actions.ts
Normal file
31
app/admin/roles/actions.ts
Normal 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
90
app/admin/roles/page.tsx
Normal 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
91
app/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
app/auth/callback/route.ts
Normal file
38
app/auth/callback/route.ts
Normal 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
84
app/dracin/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
app/dracin/categories/page.tsx
Normal file
33
app/dracin/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
app/dracin/category/[id]/page.tsx
Normal file
78
app/dracin/category/[id]/page.tsx
Normal 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
11
app/dracin/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function DracinLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
app/dracin/loading.tsx
Normal file
5
app/dracin/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LoadingSplash from "@/components/LoadingSplash";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSplash />;
|
||||
}
|
||||
58
app/dracin/page.tsx
Normal file
58
app/dracin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
app/dracin/recommend/page.tsx
Normal file
27
app/dracin/recommend/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
app/dracin/search/page.tsx
Normal file
34
app/dracin/search/page.tsx
Normal 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
27
app/dracin/vip/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
app/dracin/watch/[id]/[episode]/page.tsx
Normal file
104
app/dracin/watch/[id]/[episode]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
app/dracin/watch/loading.tsx
Normal file
5
app/dracin/watch/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LoadingSplash from "@/components/LoadingSplash";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSplash />;
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
152
app/globals.css
Normal file
152
app/globals.css
Normal 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
102
app/history/page.tsx
Normal 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
26
app/layout.tsx
Normal 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
5
app/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LoadingSplash from "@/components/LoadingSplash";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSplash />;
|
||||
}
|
||||
136
app/login/page.tsx
Normal file
136
app/login/page.tsx
Normal 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
28
app/movie/[id]/page.tsx
Normal 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
136
app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
app/profile/account/page.tsx
Normal file
154
app/profile/account/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
app/profile/bookmarks/page.tsx
Normal file
73
app/profile/bookmarks/page.tsx
Normal 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
112
app/profile/page.tsx
Normal 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
33
app/rank/page.tsx
Normal 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
158
app/register/page.tsx
Normal 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
11
app/search/actions.ts
Normal 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
214
app/search/page.tsx
Normal 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
97
app/trending/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user