feat(upload): preview uploaded videos with ProtectedVideoPlayer
This commit is contained in:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/app/(modules)/upload/components/UploadVideoDialog.tsx
Normal file
44
src/app/(modules)/upload/components/UploadVideoDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user