diff --git a/apps/web/package.json b/apps/web/package.json index 09779f4..bb3cff4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,6 +47,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "cmdk": "1.0.0", + "date-fns": "^4.1.0", "hls-video-element": "^1.5.0", "lucia": "^3.2.2", "lucide-react": "^0.473.0", @@ -58,6 +59,7 @@ "pg": "^8.14.1", "pg-boss": "^10.1.6", "react": "^19.2.3", + "react-day-picker": "^9.13.0", "react-dom": "^19.2.3", "react-hook-form": "^7.54.2", "rehype-raw": "^7.0.0", diff --git a/apps/web/src/app/(ui)/(protected)/[username]/page.tsx b/apps/web/src/app/(ui)/(protected)/[username]/page.tsx index 04e3417..0a37b12 100644 --- a/apps/web/src/app/(ui)/(protected)/[username]/page.tsx +++ b/apps/web/src/app/(ui)/(protected)/[username]/page.tsx @@ -5,11 +5,34 @@ export default async function Page({ params }: { params: Promise<{ username: str const { username } = await params; const streamInfo = await prisma.streamInfo.findUnique({ where: { username }, - include: { channel: true }, + include: { + channel: { + include: { + restriction: true, + }, + }, + }, }); if (!streamInfo) { return
Stream not found
; } + + if (streamInfo.channel.restriction) { + const isExpired = streamInfo.channel.restriction.expiresAt && + new Date(streamInfo.channel.restriction.expiresAt) < new Date(); + + if (!isExpired) { + return ( +
+

Channel Restricted

+

+ This channel has been restricted by a moderator and is not currently available for viewing. +

+
+ ); + } + } + return ( ); diff --git a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx new file mode 100644 index 0000000..edc2df1 --- /dev/null +++ b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx @@ -0,0 +1,793 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { format } from 'date-fns'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { + Users, + Tv, + Ban, + ShieldOff, + Search, + AlertTriangle, + CalendarIcon, + ShieldCheck, + ShieldMinus, + X, +} from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { toast } from 'sonner'; +import type { User } from '@hctv/db'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { cn } from '@/lib/utils'; + +export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) { + const [userSearch, setUserSearch] = useState(''); + const [channelSearch, setChannelSearch] = useState(''); + const [users, setUsers] = useState([]); + const [channels, setChannels] = useState([]); + const [usersLoading, setUsersLoading] = useState(false); + const [channelsLoading, setChannelsLoading] = useState(false); + + const [banDialogOpen, setBanDialogOpen] = useState(false); + const [restrictDialogOpen, setRestrictDialogOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedChannel, setSelectedChannel] = useState(null); + const [reason, setReason] = useState(''); + const [expiresAt, setExpiresAt] = useState(undefined); + + const fetchUsers = useCallback(async (search: string) => { + setUsersLoading(true); + try { + const res = await fetch(`/api/admin/users?search=${encodeURIComponent(search)}`); + if (res.ok) { + setUsers(await res.json()); + } + } catch (e) { + toast.error('Failed to fetch users'); + } finally { + setUsersLoading(false); + } + }, []); + + const fetchChannels = useCallback(async (search: string) => { + setChannelsLoading(true); + try { + const res = await fetch(`/api/admin/channels?search=${encodeURIComponent(search)}`); + if (res.ok) { + setChannels(await res.json()); + } + } catch (e) { + toast.error('Failed to fetch channels'); + } finally { + setChannelsLoading(false); + } + }, []); + + useEffect(() => { + fetchUsers(''); + fetchChannels(''); + }, [fetchUsers, fetchChannels]); + + useEffect(() => { + const timer = setTimeout(() => { + fetchUsers(userSearch); + }, 300); + return () => clearTimeout(timer); + }, [userSearch, fetchUsers]); + + useEffect(() => { + const timer = setTimeout(() => { + fetchChannels(channelSearch); + }, 300); + return () => clearTimeout(timer); + }, [channelSearch, fetchChannels]); + + const resetDialogState = () => { + setReason(''); + setExpiresAt(undefined); + setSelectedUser(null); + setSelectedChannel(null); + }; + + const handleBanUser = async () => { + if (!selectedUser || !reason.trim()) { + toast.error('Please provide a reason'); + return; + } + + try { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: selectedUser.id, + action: 'ban', + reason, + expiresAt: expiresAt?.toISOString(), + }), + }); + + if (res.ok) { + toast.success('User banned successfully'); + fetchUsers(userSearch); + setBanDialogOpen(false); + resetDialogState(); + } else { + const err = await res.text(); + toast.error(err || 'Failed to ban user'); + } + } catch (e) { + toast.error('Failed to ban user'); + } + }; + + const handleUnbanUser = async (userId: string) => { + try { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId, + action: 'unban', + }), + }); + + if (res.ok) { + toast.success('User unbanned successfully'); + fetchUsers(userSearch); + } else { + toast.error('Failed to unban user'); + } + } catch (e) { + toast.error('Failed to unban user'); + } + }; + + const handleRestrictChannel = async () => { + if (!selectedChannel || !reason.trim()) { + toast.error('Please provide a reason'); + return; + } + + try { + const res = await fetch('/api/admin/channels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channelId: selectedChannel.id, + action: 'restrict', + reason, + expiresAt: expiresAt?.toISOString(), + }), + }); + + if (res.ok) { + toast.success('Channel restricted successfully'); + fetchChannels(channelSearch); + setRestrictDialogOpen(false); + resetDialogState(); + } else { + const err = await res.text(); + toast.error(err || 'Failed to restrict channel'); + } + } catch (e) { + toast.error('Failed to restrict channel'); + } + }; + + const handleUnrestrictChannel = async (channelId: string) => { + try { + const res = await fetch('/api/admin/channels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channelId, + action: 'unrestrict', + }), + }); + + if (res.ok) { + toast.success('Channel unrestricted successfully'); + fetchChannels(channelSearch); + } else { + toast.error('Failed to unrestrict channel'); + } + } catch (e) { + toast.error('Failed to unrestrict channel'); + } + }; + + const handlePromoteUser = async (userId: string) => { + try { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId, + action: 'promote', + }), + }); + + if (res.ok) { + toast.success('User promoted to admin'); + fetchUsers(userSearch); + } else { + const err = await res.text(); + toast.error(err || 'Failed to promote user'); + } + } catch (e) { + toast.error('Failed to promote user'); + } + }; + + const handleDemoteUser = async (userId: string) => { + try { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId, + action: 'demote', + }), + }); + + if (res.ok) { + toast.success('User demoted from admin'); + fetchUsers(userSearch); + } else { + const err = await res.text(); + toast.error(err || 'Failed to demote user'); + } + } catch (e) { + toast.error('Failed to demote user'); + } + }; + + return ( +
+
+

+ Admin Panel +

+

Manage users and channels on the platform

+
+ + + + + + Users + + + + Channels + + + + + + + User Management + + Search and manage user accounts. Ban users to prevent them from using the platform. + + + +
+
+ + setUserSearch(e.target.value)} + className="pl-10" + /> +
+
+ + + + + User + Status + Actions + + + + {usersLoading ? ( + + + Loading... + + + ) : users.length === 0 ? ( + + + No users found + + + ) : ( + users.map((user) => ( + + +
+ + + + {user.personalChannel?.name?.[0]?.toUpperCase() || 'U'} + + +
+

+ {user.personalChannel?.name} +

+

{user.email}

+
+
+
+ +
+ {user.isAdmin && ( + Admin + )} + {user.ban ? ( + + + Banned + + ) : ( + Active + )} +
+ {user.ban && ( +
+

Reason: {user.ban.reason}

+ {user.ban.expiresAt && ( +

Expires: {format(new Date(user.ban.expiresAt), 'PPP')}

+ )} +
+ )} +
+ +
+ {user.isAdmin ? ( + user.id !== currentUser.id && ( + + ) + ) : ( + <> + {user.ban ? ( + + ) : ( + + )} + + + )} +
+
+
+ )) + )} +
+
+
+
+
+ + + + + Channel Management + + Search and manage channels. Restrict channels to prevent streams from being viewed. + + + +
+
+ + setChannelSearch(e.target.value)} + className="pl-10" + /> +
+
+ + + + + Channel + Owner + Status + Actions + + + + {channelsLoading ? ( + + + Loading... + + + ) : channels.length === 0 ? ( + + + No channels found + + + ) : ( + channels.map((channel) => ( + + +
+ + + + {channel.name[0]?.toUpperCase()} + + +
+

{channel.name}

+ {channel.personalFor && ( + Personal + )} +
+
+
+ +
+ + + O + + {channel.owner.personalChannel.name} +
+
+ + {channel.restriction ? ( + + + Restricted + + ) : ( + Active + )} + {channel.restriction && ( +
+

Reason: {channel.restriction.reason}

+ {channel.restriction.expiresAt && ( +

Expires: {format(new Date(channel.restriction.expiresAt), 'PPP')}

+ )} +
+ )} +
+ + {channel.restriction ? ( + + ) : ( + + )} + +
+ )) + )} +
+
+
+
+
+
+ + { + setBanDialogOpen(open); + if (!open) resetDialogState(); + }}> + + + Ban User + + Ban {selectedUser?.personalChannel?.name || selectedUser?.slack_id} from the platform. + They will not be able to stream or use the platform while banned. + + +
+
+ +