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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user