feat(upload): preview uploaded videos with ProtectedVideoPlayer

This commit is contained in:
2026-06-24 00:14:18 +05:30
parent 5439a5ff45
commit 81b68fdb61
8 changed files with 99 additions and 92 deletions

View File

@@ -1,53 +0,0 @@
'use client';
import { Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
import type { UploadVideo } from '@/types';
interface ProcessedVideoDialogProps {
upload: UploadVideo | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProcessedVideoDialog({
upload,
open,
onOpenChange,
}: ProcessedVideoDialogProps) {
const { videoUrl, isLoading, isError } = useAnnotatedVideoQuery(
open ? upload?.processed_video_url ?? undefined : undefined,
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{upload?.filename ?? 'Processed Video'}</DialogTitle>
<DialogDescription>Processed video preview</DialogDescription>
</DialogHeader>
<div className="flex aspect-video items-center justify-center overflow-hidden rounded-md border bg-muted/30">
{isLoading ? (
<Loader2 className="size-6 animate-spin text-primary" />
) : isError || !videoUrl ? (
<p className="text-sm text-muted-foreground">
Processed video is not available.
</p>
) : (
<video src={videoUrl} controls className="h-full w-full" />
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,14 +13,14 @@ import type { UploadVideo } from '@/types';
interface UploadActionsMenuProps { interface UploadActionsMenuProps {
upload: UploadVideo; upload: UploadVideo;
onViewVideo: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void; onViewDetails: (upload: UploadVideo) => void;
onViewProcessedVideo: (upload: UploadVideo) => void;
} }
export function UploadActionsMenu({ export function UploadActionsMenu({
upload, upload,
onViewVideo,
onViewDetails, onViewDetails,
onViewProcessedVideo,
}: UploadActionsMenuProps) { }: UploadActionsMenuProps) {
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -37,16 +37,26 @@ export function UploadActionsMenu({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onViewDetails(upload)}> {upload.video_url ? (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onViewVideo(upload);
}}
>
<PlayCircle className="size-4" />
View Video
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onViewDetails(upload);
}}
>
<Eye className="size-4" /> <Eye className="size-4" />
View Details View Details
</DropdownMenuItem> </DropdownMenuItem>
{upload.processed_video_url ? (
<DropdownMenuItem onClick={() => onViewProcessedVideo(upload)}>
<PlayCircle className="size-4" />
View Processed Video
</DropdownMenuItem>
) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@@ -21,8 +21,8 @@ interface UploadCardGridProps {
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onUploadNew: () => void; onUploadNew: () => void;
onOpenUpload: (upload: UploadVideo) => void; onOpenUpload: (upload: UploadVideo) => void;
onViewVideo: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void; onViewDetails: (upload: UploadVideo) => void;
onViewProcessedVideo: (upload: UploadVideo) => void;
} }
export function UploadCardGrid({ export function UploadCardGrid({
@@ -35,8 +35,8 @@ export function UploadCardGrid({
onLimitChange, onLimitChange,
onUploadNew, onUploadNew,
onOpenUpload, onOpenUpload,
onViewVideo,
onViewDetails, onViewDetails,
onViewProcessedVideo,
}: UploadCardGridProps) { }: UploadCardGridProps) {
const skeletonItems = Array.from({ length: Math.min(limit, 20) }); const skeletonItems = Array.from({ length: Math.min(limit, 20) });
@@ -85,8 +85,8 @@ export function UploadCardGrid({
</div> </div>
<UploadActionsMenu <UploadActionsMenu
upload={upload} upload={upload}
onViewVideo={onViewVideo}
onViewDetails={onViewDetails} onViewDetails={onViewDetails}
onViewProcessedVideo={onViewProcessedVideo}
/> />
</div> </div>
</CardContent> </CardContent>

View File

@@ -10,13 +10,13 @@ import { Badge } from '@/components/ui/badge';
import type { UploadVideo } from '@/types'; import type { UploadVideo } from '@/types';
interface UseUploadColumnsProps { interface UseUploadColumnsProps {
onViewVideo: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void; onViewDetails: (upload: UploadVideo) => void;
onViewProcessedVideo: (upload: UploadVideo) => void;
} }
export function useUploadColumns({ export function useUploadColumns({
onViewVideo,
onViewDetails, onViewDetails,
onViewProcessedVideo,
}: UseUploadColumnsProps) { }: UseUploadColumnsProps) {
return useMemo<ColumnDef<UploadVideo>[]>( return useMemo<ColumnDef<UploadVideo>[]>(
() => [ () => [
@@ -65,13 +65,13 @@ export function useUploadColumns({
<div className="flex justify-center"> <div className="flex justify-center">
<UploadActionsMenu <UploadActionsMenu
upload={row.original} upload={row.original}
onViewVideo={onViewVideo}
onViewDetails={onViewDetails} onViewDetails={onViewDetails}
onViewProcessedVideo={onViewProcessedVideo}
/> />
</div> </div>
), ),
}, },
], ],
[onViewDetails, onViewProcessedVideo], [onViewDetails, onViewVideo],
); );
} }

View File

@@ -0,0 +1,44 @@
'use client';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { UploadVideo } from '@/types';
interface UploadVideoDialogProps {
upload: UploadVideo | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UploadVideoDialog({
upload,
open,
onOpenChange,
}: UploadVideoDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-3 p-3 sm:max-w-5xl sm:p-4">
<DialogHeader className="min-w-0 pr-8">
<DialogTitle className="truncate text-left text-base">
Video Preview
</DialogTitle>
<DialogDescription className="truncate text-xs">
{upload?.filename ?? 'Uploaded video'}
</DialogDescription>
</DialogHeader>
<ProtectedVideoPlayer
url={open ? upload?.video_url : undefined}
className="rounded-lg"
unavailableTitle="Video preview unavailable"
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -8,11 +8,11 @@ import { Button } from '@/components/ui/button';
import type { UploadVideo } from '@/types'; import type { UploadVideo } from '@/types';
import { CreateUploadDialog } from './components/CreateUploadDialog'; import { CreateUploadDialog } from './components/CreateUploadDialog';
import { ProcessedVideoDialog } from './components/ProcessedVideoDialog';
import { UploadCardGrid } from './components/UploadCardGrid'; import { UploadCardGrid } from './components/UploadCardGrid';
import { useUploadColumns } from './components/UploadColumns'; import { useUploadColumns } from './components/UploadColumns';
import { UploadDetailsDialog } from './components/UploadDetailsDialog'; import { UploadDetailsDialog } from './components/UploadDetailsDialog';
import { UploadTable } from './components/UploadTable'; import { UploadTable } from './components/UploadTable';
import { UploadVideoDialog } from './components/UploadVideoDialog';
import { ViewSwitcher } from './components/ViewSwitcher'; import { ViewSwitcher } from './components/ViewSwitcher';
import { useUploadFilters } from './hooks/useUploadFilters'; import { useUploadFilters } from './hooks/useUploadFilters';
import { useMyUploadsQuery } from './hooks/useUploadQueries'; import { useMyUploadsQuery } from './hooks/useUploadQueries';
@@ -23,9 +23,7 @@ export default function UploadPage() {
const uploadsQuery = useMyUploadsQuery({ skip, limit }); const uploadsQuery = useMyUploadsQuery({ skip, limit });
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [detailsUpload, setDetailsUpload] = useState<UploadVideo | null>(null); const [detailsUpload, setDetailsUpload] = useState<UploadVideo | null>(null);
const [processedUpload, setProcessedUpload] = useState<UploadVideo | null>( const [videoUpload, setVideoUpload] = useState<UploadVideo | null>(null);
null,
);
const uploads = uploadsQuery.data?.items ?? []; const uploads = uploadsQuery.data?.items ?? [];
const total = uploadsQuery.data?.total ?? 0; const total = uploadsQuery.data?.total ?? 0;
@@ -35,24 +33,22 @@ export default function UploadPage() {
setDetailsUpload(upload); setDetailsUpload(upload);
}, []); }, []);
const handleViewProcessedVideo = useCallback((upload: UploadVideo) => { const handleViewUploadVideo = useCallback((upload: UploadVideo) => {
setProcessedUpload(upload); setVideoUpload(upload);
}, []); }, []);
const handleOpenUpload = useCallback( const handleOpenUpload = useCallback(
(upload: UploadVideo) => { (upload: UploadVideo) => {
if (upload.processed_video_url) { if (upload.video_url) {
handleViewProcessedVideo(upload); handleViewUploadVideo(upload);
return;
} }
handleViewDetails(upload);
}, },
[handleViewDetails, handleViewProcessedVideo], [handleViewUploadVideo],
); );
const columns = useUploadColumns({ const columns = useUploadColumns({
onViewVideo: handleViewUploadVideo,
onViewDetails: handleViewDetails, onViewDetails: handleViewDetails,
onViewProcessedVideo: handleViewProcessedVideo,
}); });
return ( return (
@@ -85,8 +81,8 @@ export default function UploadPage() {
onLimitChange={setLimit} onLimitChange={setLimit}
onUploadNew={() => setIsCreateOpen(true)} onUploadNew={() => setIsCreateOpen(true)}
onOpenUpload={handleOpenUpload} onOpenUpload={handleOpenUpload}
onViewVideo={handleViewUploadVideo}
onViewDetails={handleViewDetails} onViewDetails={handleViewDetails}
onViewProcessedVideo={handleViewProcessedVideo}
/> />
) : ( ) : (
<UploadTable <UploadTable
@@ -111,11 +107,11 @@ export default function UploadPage() {
if (!open) setDetailsUpload(null); if (!open) setDetailsUpload(null);
}} }}
/> />
<ProcessedVideoDialog <UploadVideoDialog
upload={processedUpload} upload={videoUpload}
open={Boolean(processedUpload)} open={Boolean(videoUpload)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setProcessedUpload(null); if (!open) setVideoUpload(null);
}} }}
/> />
</> </>

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { Video } from 'lucide-react'; import { Loader2, Video } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia'; import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -39,9 +38,20 @@ export function ProtectedVideoPlayer({
if (isLoading) { if (isLoading) {
return ( return (
<Skeleton <div
className={cn('aspect-video w-full rounded-md border', className)} className={cn(
/> 'flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md border bg-muted/30 text-foreground',
className,
)}
>
<Loader2 className="size-7 animate-spin text-primary" />
<div className="text-center">
<p className="text-sm font-medium">Loading video</p>
<p className="mt-1 text-xs text-muted-foreground">
Preparing secure playback...
</p>
</div>
</div>
); );
} }

View File

@@ -13,6 +13,7 @@ export interface UploadVideo {
uploaded_at: string; uploaded_at: string;
uploaded_by_user_id?: number | null; uploaded_by_user_id?: number | null;
uploaded_by_email?: string | null; uploaded_by_email?: string | null;
video_url?: string | null;
processed_video_path?: string | null; processed_video_path?: string | null;
processed_video_url?: string | null; processed_video_url?: string | null;
status: UploadVideoStatus; status: UploadVideoStatus;
@@ -22,4 +23,3 @@ export interface UploadVideosResponse {
items: UploadVideo[]; items: UploadVideo[];
total: number; total: number;
} }