354 lines
13 KiB
TypeScript
354 lines
13 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from 'sonner'
|
|
import { roomsApi, type RoomDto } from '@/api/rooms'
|
|
import type { AxiosError } from 'axios'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Search, Plus, Trash2, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react'
|
|
import { CreateAdminRoomDialog } from '@/components/CreateAdminRoomDialog'
|
|
|
|
export default function RoomsPage() {
|
|
const queryClient = useQueryClient()
|
|
const [page, setPage] = useState(1)
|
|
const [search, setSearch] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<'WAITING' | 'PLAYING' | 'FINISHED' | ''>('')
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
|
const [roomToDelete, setRoomToDelete] = useState<RoomDto | null>(null)
|
|
|
|
const limit = 20
|
|
|
|
// Fetch rooms
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['admin', 'rooms', page, statusFilter],
|
|
queryFn: () => roomsApi.getRooms({
|
|
page,
|
|
limit,
|
|
status: statusFilter || undefined,
|
|
}),
|
|
})
|
|
|
|
// Delete mutation
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (roomId: string) => roomsApi.deleteRoom(roomId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin', 'rooms'] })
|
|
toast.success('Room deleted successfully')
|
|
setIsDeleteDialogOpen(false)
|
|
setRoomToDelete(null)
|
|
},
|
|
onError: (error: unknown) => {
|
|
const axiosError = error as AxiosError<{ message?: string }>
|
|
toast.error(axiosError.response?.data?.message || 'Failed to delete room')
|
|
},
|
|
})
|
|
|
|
const handleDelete = (room: RoomDto) => {
|
|
setRoomToDelete(room)
|
|
setIsDeleteDialogOpen(true)
|
|
}
|
|
|
|
const confirmDelete = () => {
|
|
if (roomToDelete?.id) {
|
|
deleteMutation.mutate(roomToDelete.id)
|
|
}
|
|
}
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
switch (status) {
|
|
case 'WAITING':
|
|
return <Badge variant="secondary">Waiting</Badge>
|
|
case 'PLAYING':
|
|
return <Badge variant="default">Playing</Badge>
|
|
case 'FINISHED':
|
|
return <Badge variant="outline">Finished</Badge>
|
|
default:
|
|
return <Badge>{status}</Badge>
|
|
}
|
|
}
|
|
|
|
const filteredRooms = data?.rooms.filter(room =>
|
|
search === '' ||
|
|
room.code.toLowerCase().includes(search.toLowerCase()) ||
|
|
room.host.name?.toLowerCase().includes(search.toLowerCase()) ||
|
|
room.host.email?.toLowerCase().includes(search.toLowerCase())
|
|
) || []
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Rooms Management</h1>
|
|
<p className="text-muted-foreground">Error loading rooms</p>
|
|
</div>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-red-500">Failed to load rooms. Please try again later.</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Rooms Management</h1>
|
|
<p className="text-muted-foreground">
|
|
View and manage game rooms
|
|
</p>
|
|
</div>
|
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Create Room
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="flex items-center space-x-2 flex-1">
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search by code, host name or email..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="max-w-sm"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant={statusFilter === '' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setStatusFilter('')}
|
|
>
|
|
All
|
|
</Button>
|
|
<Button
|
|
variant={statusFilter === 'WAITING' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setStatusFilter('WAITING')}
|
|
>
|
|
Waiting
|
|
</Button>
|
|
<Button
|
|
variant={statusFilter === 'PLAYING' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setStatusFilter('PLAYING')}
|
|
>
|
|
Playing
|
|
</Button>
|
|
<Button
|
|
variant={statusFilter === 'FINISHED' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setStatusFilter('FINISHED')}
|
|
>
|
|
Finished
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Rooms Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>All Rooms ({data?.total || 0})</CardTitle>
|
|
<CardDescription>
|
|
Manage game rooms and their settings
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className="text-center py-8">Loading rooms...</div>
|
|
) : (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Code</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Host</TableHead>
|
|
<TableHead>Theme</TableHead>
|
|
<TableHead>Pack</TableHead>
|
|
<TableHead>Players</TableHead>
|
|
<TableHead>Created</TableHead>
|
|
<TableHead>Active Period</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredRooms.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
|
No rooms found
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredRooms.map((room) => (
|
|
<TableRow key={room.id}>
|
|
<TableCell className="font-mono font-medium">
|
|
{room.code}
|
|
{room.isAdminRoom && (
|
|
<Badge variant="secondary" className="ml-2">Admin</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{getStatusBadge(room.status)}</TableCell>
|
|
<TableCell>
|
|
<div>
|
|
<div className="font-medium">{room.host.name || 'No name'}</div>
|
|
{room.host.email && (
|
|
<div className="text-sm text-muted-foreground">{room.host.email}</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{room.theme ? (
|
|
<Badge variant="outline">{room.theme.name}</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground">Default</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{room.questionPack ? (
|
|
<span className="text-sm">{room.questionPack.name}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{room._count?.participants || 0} / {room.maxPlayers}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{new Date(room.createdAt).toLocaleDateString()}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{room.activeFrom && room.activeTo ? (
|
|
<div>
|
|
<div>{new Date(room.activeFrom).toLocaleDateString()}</div>
|
|
<div className="text-xs">to {new Date(room.activeTo).toLocaleDateString()}</div>
|
|
</div>
|
|
) : (
|
|
<span>-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => window.open(`/join/${room.code}`, '_blank')}
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDelete(room)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{/* Pagination */}
|
|
{data && data.totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4">
|
|
<div className="text-sm text-muted-foreground">
|
|
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} rooms
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(page - 1)}
|
|
disabled={page <= 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<span className="text-sm">
|
|
Page {page} of {data.totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={page >= data.totalPages}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Create Room Dialog */}
|
|
{createDialogOpen && (
|
|
<CreateAdminRoomDialog
|
|
open={createDialogOpen}
|
|
onClose={() => setCreateDialogOpen(false)}
|
|
onSuccess={() => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin', 'rooms'] })
|
|
setCreateDialogOpen(false)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Room</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete the room "{roomToDelete?.code}"? This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={confirmDelete}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)
|
|
}
|
|
|