mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
refactor(video-page): split video page into modular components, hooks, and shared types
This commit is contained in:
+615
-4539
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import { memo, type RefObject } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { AnnotationStroke } from '@/components/annotation-canvas';
|
||||
import type { CommentTag } from '@/components/video-page/types';
|
||||
|
||||
interface CommentComposerProps {
|
||||
isRecording: boolean;
|
||||
recordingTime: number;
|
||||
stopRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
audioBlob: Blob | null;
|
||||
imageBlob: File | null;
|
||||
imageInputRef: RefObject<HTMLInputElement | null>;
|
||||
setImageBlob: (blob: File | null) => void;
|
||||
commentText: string;
|
||||
setCommentText: (value: string) => void;
|
||||
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
|
||||
playingVoiceId: string | null;
|
||||
voiceProgress: number;
|
||||
voiceCurrentTime: number;
|
||||
formatTime: (value: number) => string;
|
||||
toggleVoiceSpeed: () => void;
|
||||
voicePlaybackRate: number;
|
||||
submitCommentWithMedia: () => void;
|
||||
isUploadingAudio: boolean;
|
||||
isUploadingImage: boolean;
|
||||
annotationStrokes: AnnotationStroke[] | null;
|
||||
isAnnotating: boolean;
|
||||
setAnnotationStrokes: (strokes: AnnotationStroke[] | null) => void;
|
||||
setIsAnnotating: (value: boolean) => void;
|
||||
handleAddComment: () => void;
|
||||
isSubmittingComment: boolean;
|
||||
startRecording: () => void;
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void;
|
||||
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void;
|
||||
availableTags: CommentTag[];
|
||||
selectedTagId: string | null;
|
||||
setSelectedTagId: (value: string | null) => void;
|
||||
canManageTags: boolean;
|
||||
projectId?: string;
|
||||
pauseVideoForAnnotation: () => void;
|
||||
}
|
||||
|
||||
export const CommentComposer = memo(function CommentComposer({
|
||||
isRecording,
|
||||
recordingTime,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
audioBlob,
|
||||
imageBlob,
|
||||
imageInputRef,
|
||||
setImageBlob,
|
||||
commentText,
|
||||
setCommentText,
|
||||
playVoice,
|
||||
playingVoiceId,
|
||||
voiceProgress,
|
||||
voiceCurrentTime,
|
||||
formatTime,
|
||||
toggleVoiceSpeed,
|
||||
voicePlaybackRate,
|
||||
submitCommentWithMedia,
|
||||
isUploadingAudio,
|
||||
isUploadingImage,
|
||||
annotationStrokes,
|
||||
isAnnotating,
|
||||
setAnnotationStrokes,
|
||||
setIsAnnotating,
|
||||
handleAddComment,
|
||||
isSubmittingComment,
|
||||
startRecording,
|
||||
handlePaste,
|
||||
handleImageSelect,
|
||||
availableTags,
|
||||
selectedTagId,
|
||||
setSelectedTagId,
|
||||
canManageTags,
|
||||
projectId,
|
||||
pauseVideoForAnnotation,
|
||||
}: CommentComposerProps) {
|
||||
return (
|
||||
<div className="shrink-0 p-4 border-t bg-background">
|
||||
{isRecording ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-destructive/10 border border-destructive/30 rounded-lg">
|
||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||
<span className="text-sm font-medium text-destructive">
|
||||
Recording {formatTime(recordingTime)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="destructive" onClick={stopRecording}>
|
||||
Stop
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelRecording}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : audioBlob ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
const url = URL.createObjectURL(audioBlob);
|
||||
playVoice('preview', url, recordingTime);
|
||||
}}
|
||||
>
|
||||
{playingVoiceId === 'preview' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: playingVoiceId === 'preview' ? `${voiceProgress}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{playingVoiceId === 'preview'
|
||||
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
|
||||
: formatTime(recordingTime)}
|
||||
</span>
|
||||
{playingVoiceId === 'preview' && (
|
||||
<button
|
||||
onClick={toggleVoiceSpeed}
|
||||
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
|
||||
>
|
||||
{voicePlaybackRate}x
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={cancelRecording}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{imageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button size="icon" variant="destructive" onClick={() => {
|
||||
setImageBlob(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
placeholder="Add a note to your voice comment (optional)..."
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
rows={1}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submitCommentWithMedia}
|
||||
disabled={isUploadingAudio || isUploadingImage}
|
||||
className="w-full"
|
||||
>
|
||||
{isUploadingAudio || isUploadingImage ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Uploading Media...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Voice Comment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(annotationStrokes || isAnnotating) && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 mb-2 rounded-md bg-violet-500/10 border border-violet-500/30">
|
||||
<Pencil className="h-3.5 w-3.5 text-violet-500 shrink-0" />
|
||||
<span className="text-xs text-violet-400 font-medium">Annotation attached</span>
|
||||
<button
|
||||
className="ml-auto text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => { setAnnotationStrokes(null); setIsAnnotating(false); }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{imageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button size="icon" variant="destructive" onClick={() => {
|
||||
setImageBlob(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
placeholder="Add a comment..."
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleAddComment();
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleAddComment}
|
||||
disabled={(!commentText.trim() && !imageBlob && !annotationStrokes) || isSubmittingComment || isUploadingImage}
|
||||
>
|
||||
{isSubmittingComment || isUploadingImage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={startRecording}
|
||||
title="Record voice comment"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
title="Attach Image"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={annotationStrokes ? 'default' : 'outline'}
|
||||
className={annotationStrokes ? 'bg-violet-500 hover:bg-violet-600' : ''}
|
||||
onClick={() => {
|
||||
if (isAnnotating) return;
|
||||
pauseVideoForAnnotation();
|
||||
setIsAnnotating(true);
|
||||
}}
|
||||
title={annotationStrokes ? 'Annotation added ✓ (click to redraw)' : 'Draw annotation on video'}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
ref={imageInputRef}
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
{availableTags.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={selectedTagId ? 'default' : 'outline'}
|
||||
title="Select tag"
|
||||
style={selectedTagId ? {
|
||||
backgroundColor: availableTags.find(t => t.id === selectedTagId)?.color
|
||||
} : undefined}
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{availableTags.map((tag) => (
|
||||
<DropdownMenuItem
|
||||
key={tag.id}
|
||||
onClick={() => setSelectedTagId(tag.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name}
|
||||
{selectedTagId === tag.id && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{canManageTags && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
|
||||
<Tag className="h-3 w-3" />
|
||||
Manage Tags
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,814 @@
|
||||
'use client';
|
||||
|
||||
import { memo, type ReactNode, type RefObject } from 'react';
|
||||
import { ArrowUpRight, CheckCircle2, Circle, Clock, Download, FileText, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Linkify } from '@/components/linkify';
|
||||
import type { Comment, CommentTag, Version } from '@/components/video-page/types';
|
||||
|
||||
interface CommentsPaneProps {
|
||||
isMobileCommentsOpen: boolean;
|
||||
setIsMobileCommentsOpen: (open: boolean) => void;
|
||||
isFullscreenMode: boolean;
|
||||
showComments: boolean;
|
||||
comments: Comment[];
|
||||
filteredComments: Comment[];
|
||||
sortedComments: Comment[];
|
||||
showResolved: boolean;
|
||||
handleToggleShowResolved: () => void;
|
||||
activeVersion: Version | undefined;
|
||||
isGuest: boolean;
|
||||
isExportingCsv: boolean;
|
||||
isExportingPdf: boolean;
|
||||
handleExportComments: (format: 'csv' | 'pdf') => void;
|
||||
canResolveComments: boolean;
|
||||
handleResolveComment: (commentId: string, currentlyResolved: boolean) => void;
|
||||
handleSeekToTimestamp: (timestamp: number, annotation?: string | null) => void;
|
||||
currentUserId: string | null;
|
||||
projectOwnerId: string;
|
||||
editingCommentId: string | null;
|
||||
setEditingCommentId: (id: string | null) => void;
|
||||
editText: string;
|
||||
setEditText: (value: string) => void;
|
||||
editTagId: string | null;
|
||||
setEditTagId: (value: string | null) => void;
|
||||
setEditAnnotationData: (value: string | null | undefined) => void;
|
||||
setIsEditingAnnotation: (value: boolean) => void;
|
||||
onStartEditAnnotation: () => void;
|
||||
isSubmittingEdit: boolean;
|
||||
availableTags: CommentTag[];
|
||||
handleEditComment: (commentId: string) => void;
|
||||
handleDeleteComment: (commentId: string) => void;
|
||||
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
|
||||
playingVoiceId: string | null;
|
||||
voiceProgress: number;
|
||||
voiceCurrentTime: number;
|
||||
voicePlaybackRate: number;
|
||||
toggleVoiceSpeed: () => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
setPreviewImage: (url: string | null) => void;
|
||||
replyingTo: string | null;
|
||||
setReplyingTo: (id: string | null) => void;
|
||||
replyText: string;
|
||||
setReplyText: (value: string) => void;
|
||||
handleReplyComment: (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => void;
|
||||
startReplyRecording: () => void;
|
||||
isReplyRecording: boolean;
|
||||
replyRecordingTime: number;
|
||||
stopReplyRecording: () => void;
|
||||
cancelReplyRecording: () => void;
|
||||
replyAudioBlob: Blob | null;
|
||||
replyImageBlob: File | null;
|
||||
setReplyImageBlob: (file: File | null) => void;
|
||||
replyImageInputRef: RefObject<HTMLInputElement | null>;
|
||||
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void;
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void;
|
||||
submitReplyWithMedia: (parentId: string) => void;
|
||||
isSubmittingReply: boolean;
|
||||
isUploadingReplyAudio: boolean;
|
||||
isUploadingReplyImage: boolean;
|
||||
composer: ReactNode;
|
||||
}
|
||||
|
||||
export const CommentsPane = memo(function CommentsPane({
|
||||
isMobileCommentsOpen,
|
||||
setIsMobileCommentsOpen,
|
||||
isFullscreenMode,
|
||||
showComments,
|
||||
comments,
|
||||
filteredComments,
|
||||
sortedComments,
|
||||
showResolved,
|
||||
handleToggleShowResolved,
|
||||
activeVersion,
|
||||
isGuest,
|
||||
isExportingCsv,
|
||||
isExportingPdf,
|
||||
handleExportComments,
|
||||
canResolveComments,
|
||||
handleResolveComment,
|
||||
handleSeekToTimestamp,
|
||||
currentUserId,
|
||||
projectOwnerId,
|
||||
editingCommentId,
|
||||
setEditingCommentId,
|
||||
editText,
|
||||
setEditText,
|
||||
editTagId,
|
||||
setEditTagId,
|
||||
setEditAnnotationData,
|
||||
setIsEditingAnnotation,
|
||||
onStartEditAnnotation,
|
||||
isSubmittingEdit,
|
||||
availableTags,
|
||||
handleEditComment,
|
||||
handleDeleteComment,
|
||||
playVoice,
|
||||
playingVoiceId,
|
||||
voiceProgress,
|
||||
voiceCurrentTime,
|
||||
voicePlaybackRate,
|
||||
toggleVoiceSpeed,
|
||||
formatTime,
|
||||
setPreviewImage,
|
||||
replyingTo,
|
||||
setReplyingTo,
|
||||
replyText,
|
||||
setReplyText,
|
||||
handleReplyComment,
|
||||
startReplyRecording,
|
||||
isReplyRecording,
|
||||
replyRecordingTime,
|
||||
stopReplyRecording,
|
||||
cancelReplyRecording,
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
setReplyImageBlob,
|
||||
replyImageInputRef,
|
||||
handleImageSelect,
|
||||
handlePaste,
|
||||
submitReplyWithMedia,
|
||||
isSubmittingReply,
|
||||
isUploadingReplyAudio,
|
||||
isUploadingReplyImage,
|
||||
composer,
|
||||
}: CommentsPaneProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden transition-opacity duration-300',
|
||||
isMobileCommentsOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={() => setIsMobileCommentsOpen(false)}
|
||||
/>
|
||||
|
||||
<div className={cn(
|
||||
'bg-card flex flex-col overflow-hidden z-50',
|
||||
'fixed inset-y-0 right-0 w-[85%] sm:w-[400px] shadow-2xl transition-transform duration-300 transform',
|
||||
isMobileCommentsOpen ? 'translate-x-0' : 'translate-x-full',
|
||||
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
|
||||
isFullscreenMode && !showComments ? 'hidden' : ''
|
||||
)}>
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between p-4 border-b lg:cursor-default"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span className="font-medium">Comments</span>
|
||||
<Badge variant="secondary">{comments.length}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}>
|
||||
{showResolved ? 'Hide' : 'Show'} Resolved
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExportComments('csv');
|
||||
}}
|
||||
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
|
||||
>
|
||||
{isExportingCsv ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExportComments('pdf');
|
||||
}}
|
||||
title="Download comments as PDF"
|
||||
>
|
||||
{isExportingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setIsMobileCommentsOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{filteredComments.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No comments yet</p>
|
||||
<p className="text-sm">Be the first to leave feedback!</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedComments.map((comment) => {
|
||||
const authorName =
|
||||
comment.author?.name || comment.guestName || 'Anonymous';
|
||||
const isEditing = editingCommentId === comment.id;
|
||||
const isReplying = replyingTo === comment.id;
|
||||
const canEditComment = comment.canEdit ?? (comment.author?.id === currentUserId);
|
||||
const canDeleteComment = comment.canDelete ?? (comment.author?.id === currentUserId || projectOwnerId === currentUserId);
|
||||
const canManageComment = canEditComment || canDeleteComment;
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={cn(
|
||||
'group rounded-lg border p-3 transition-colors hover:bg-accent/50',
|
||||
comment.isResolved && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
<AvatarImage src={comment.author?.image ?? undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{authorName.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium truncate">{authorName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => handleSeekToTimestamp(comment.timestamp, comment.annotationData)}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||
title="Jump to this timestamp"
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(comment.timestamp)}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</button>
|
||||
{canResolveComments && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
handleResolveComment(comment.id, comment.isResolved)
|
||||
}
|
||||
>
|
||||
{comment.isResolved ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canManageComment && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
setReplyingTo(comment.id);
|
||||
setReplyText('');
|
||||
}}>
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
Reply
|
||||
</DropdownMenuItem>
|
||||
{canEditComment && (
|
||||
<DropdownMenuItem onClick={() => {
|
||||
setEditingCommentId(comment.id);
|
||||
setEditText(comment.content || '');
|
||||
setEditTagId(comment.tag?.id || null);
|
||||
}}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDeleteComment && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mb-2">
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none text-sm mb-1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleEditComment(comment.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
setEditTagId(null);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleEditComment(comment.id)}
|
||||
disabled={!editText.trim() || isSubmittingEdit}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSubmittingEdit ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => { setEditingCommentId(null); setEditText(''); setEditTagId(null); setEditAnnotationData(undefined); setIsEditingAnnotation(false); }}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={comment.annotationData ? 'default' : 'outline'}
|
||||
className={`h-7 w-7 ${comment.annotationData ? 'bg-violet-500 hover:bg-violet-600' : ''}`}
|
||||
onClick={() => {
|
||||
onStartEditAnnotation();
|
||||
}}
|
||||
title={comment.annotationData ? 'Redraw annotation' : 'Add annotation'}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{availableTags.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={editTagId ? 'default' : 'outline'}
|
||||
className="h-7 text-xs ml-auto"
|
||||
style={editTagId ? {
|
||||
backgroundColor: availableTags.find(t => t.id === editTagId)?.color
|
||||
} : undefined}
|
||||
>
|
||||
<Tag className="h-3 w-3 mr-1" />
|
||||
{editTagId ? availableTags.find(t => t.id === editTagId)?.name || 'Tag' : 'Tag'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditTagId(null)} className="gap-2">
|
||||
<X className="h-3 w-3" />
|
||||
No Tag
|
||||
{!editTagId && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{availableTags.map((tag) => (
|
||||
<DropdownMenuItem
|
||||
key={tag.id}
|
||||
onClick={() => setEditTagId(tag.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name}
|
||||
{editTagId === tag.id && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2">
|
||||
{comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>}
|
||||
{comment.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setPreviewImage(comment.imageUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={comment.imageUrl} alt="Attachment" className="max-h-60 w-auto object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comment.voiceUrl && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => playVoice(comment.id, comment.voiceUrl!, comment.voiceDuration || 0)}
|
||||
>
|
||||
{playingVoiceId === comment.id ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: playingVoiceId === comment.id ? `${voiceProgress}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||
{playingVoiceId === comment.id
|
||||
? `${formatTime(voiceCurrentTime)} / ${formatTime(comment.voiceDuration || 0)}`
|
||||
: formatTime(comment.voiceDuration || 0)}
|
||||
</span>
|
||||
{playingVoiceId === comment.id && (
|
||||
<button
|
||||
onClick={toggleVoiceSpeed}
|
||||
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
|
||||
>
|
||||
{voicePlaybackRate}x
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(comment.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
{comment.annotationData && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-violet-500 text-white shrink-0 flex items-center gap-1">
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
Annotated
|
||||
</span>
|
||||
)}
|
||||
{comment.tag && (
|
||||
<span
|
||||
className="text-[10px] font-medium px-2 py-0.5 rounded-full text-white shrink-0"
|
||||
style={{ backgroundColor: comment.tag.color }}
|
||||
>
|
||||
{comment.tag.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{comment.replies && comment.replies.length > 0 && (
|
||||
<div className="mt-3 pl-3 border-l-2 space-y-2">
|
||||
{comment.replies.map((reply) => {
|
||||
const replyAuthor =
|
||||
reply.author?.name || reply.guestName || 'Anonymous';
|
||||
const isEditingReply = editingCommentId === reply.id;
|
||||
const canEditReply = reply.canEdit ?? (reply.author?.id === currentUserId);
|
||||
const canDeleteReply = reply.canDelete ?? (reply.author?.id === currentUserId || projectOwnerId === currentUserId);
|
||||
const canManageReply = canEditReply || canDeleteReply;
|
||||
return (
|
||||
<div key={reply.id} className="group/reply text-sm">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarFallback className="text-xs">
|
||||
{replyAuthor.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium text-xs">{replyAuthor}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(reply.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{canManageReply && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canEditReply && (
|
||||
<DropdownMenuItem onClick={() => {
|
||||
setEditingCommentId(reply.id);
|
||||
setEditText(reply.content || '');
|
||||
}}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDeleteReply && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteComment(reply.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{isEditingReply ? (
|
||||
<div className="mb-1">
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none text-sm mb-1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleEditComment(reply.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleEditComment(reply.id)}
|
||||
disabled={!editText.trim() || isSubmittingEdit}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSubmittingEdit ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => { setEditingCommentId(null); setEditText(''); }}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-1">
|
||||
{reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>}
|
||||
{reply.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setPreviewImage(reply.imageUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={reply.imageUrl} alt="Attachment" className="max-h-40 w-auto object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{reply.voiceUrl && (
|
||||
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => playVoice(reply.id, reply.voiceUrl!, reply.voiceDuration || 0)}
|
||||
>
|
||||
{playingVoiceId === reply.id ? (
|
||||
<Pause className="h-3 w-3" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: playingVoiceId === reply.id ? `${voiceProgress}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||
{playingVoiceId === reply.id
|
||||
? `${formatTime(voiceCurrentTime)} / ${formatTime(reply.voiceDuration || 0)}`
|
||||
: formatTime(reply.voiceDuration || 0)}
|
||||
</span>
|
||||
{playingVoiceId === reply.id && (
|
||||
<button
|
||||
onClick={toggleVoiceSpeed}
|
||||
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
|
||||
>
|
||||
{voicePlaybackRate}x
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReplying && (
|
||||
<div className="mt-3 pl-3 border-l-2">
|
||||
{isReplyRecording ? (
|
||||
<div className="flex items-center gap-2 p-2 bg-destructive/10 border border-destructive/30 rounded-lg mb-1">
|
||||
<div className="h-2 w-2 rounded-full bg-destructive animate-pulse" />
|
||||
<span className="text-xs font-medium text-destructive">
|
||||
{formatTime(replyRecordingTime)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="destructive" onClick={stopReplyRecording} className="h-6 text-xs">
|
||||
Stop
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-6 text-xs">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : replyAudioBlob ? (
|
||||
<div className="space-y-1 mb-1">
|
||||
<div className="flex items-center gap-2 p-2 bg-muted rounded-lg">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
const url = URL.createObjectURL(replyAudioBlob);
|
||||
playVoice('reply-preview', url, replyRecordingTime);
|
||||
}}
|
||||
>
|
||||
{playingVoiceId === 'reply-preview' ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3" />}
|
||||
</Button>
|
||||
<div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: playingVoiceId === 'reply-preview' ? `${voiceProgress}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{playingVoiceId === 'reply-preview'
|
||||
? `${formatTime(voiceCurrentTime)} / ${formatTime(replyRecordingTime)}`
|
||||
: formatTime(replyRecordingTime)}
|
||||
</span>
|
||||
{playingVoiceId === 'reply-preview' && (
|
||||
<button
|
||||
onClick={toggleVoiceSpeed}
|
||||
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
|
||||
>
|
||||
{voicePlaybackRate}x
|
||||
</button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={cancelReplyRecording}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{replyImageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
|
||||
setReplyImageBlob(null);
|
||||
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
||||
}}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Add a note (optional)..."
|
||||
rows={1}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
<div className="flex gap-1 mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => submitReplyWithMedia(comment.id)}
|
||||
disabled={isUploadingReplyAudio || isUploadingReplyImage}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isUploadingReplyAudio || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Reply'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{replyImageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
|
||||
setReplyImageBlob(null);
|
||||
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
||||
}}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Write a reply..."
|
||||
rows={2}
|
||||
className="resize-none text-sm flex-1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleReplyComment(comment.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setReplyingTo(null);
|
||||
setReplyText('');
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, true)}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={startReplyRecording}
|
||||
title="Record voice reply"
|
||||
className="h-8 w-8 shrink-0 self-end"
|
||||
>
|
||||
<Mic className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => replyImageInputRef.current?.click()}
|
||||
title="Attach Image"
|
||||
className="h-8 w-8 shrink-0 self-end"
|
||||
>
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
ref={replyImageInputRef}
|
||||
onChange={(e) => handleImageSelect(e, true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleReplyComment(comment.id)}
|
||||
disabled={(!replyText.trim() && !replyImageBlob) || isSubmittingReply || isUploadingReplyImage}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSubmittingReply || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => { setReplyingTo(null); setReplyText(''); }}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReplying && !isEditing && (
|
||||
<button
|
||||
onClick={() => { setReplyingTo(comment.id); setReplyText(''); }}
|
||||
className="mt-2 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Reply className="h-3 w-3" />
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{composer}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { CheckCircle2, GitCompareArrows } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Version } from '@/components/video-page/types';
|
||||
|
||||
interface CompareVersionsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
versions: Version[];
|
||||
selectedCompareVersions: Set<string>;
|
||||
onToggleVersion: (versionId: string) => void;
|
||||
onCompare: () => void;
|
||||
}
|
||||
|
||||
export const CompareVersionsDialog = memo(function CompareVersionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
versions,
|
||||
selectedCompareVersions,
|
||||
onToggleVersion,
|
||||
onCompare,
|
||||
}: CompareVersionsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Versions to Compare</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose 2 or more versions to compare side by side.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 mt-2 max-h-64 overflow-y-auto">
|
||||
{versions
|
||||
.slice()
|
||||
.sort((a, b) => a.versionNumber - b.versionNumber)
|
||||
.map((v) => {
|
||||
const isSelected = selectedCompareVersions.has(v.id);
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => onToggleVersion(v.id)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors',
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary">v{v.versionNumber}</Badge>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{v.versionLabel || `Version ${v.versionNumber}`}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground shrink-0">
|
||||
{v._count.comments} comments
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full mt-2"
|
||||
disabled={selectedCompareVersions.size < 2}
|
||||
onClick={onCompare}
|
||||
>
|
||||
<GitCompareArrows className="h-4 w-4 mr-2" />
|
||||
Compare {selectedCompareVersions.size} Versions
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { ChevronDown, Download, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
|
||||
|
||||
interface DownloadControlsProps {
|
||||
activeVersion: Version | null | undefined;
|
||||
videoCanDownload: boolean;
|
||||
isDownloading: boolean;
|
||||
activeDownloadTarget: DownloadTarget | null;
|
||||
onDownload: (preference?: BunnyDownloadPreference) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface DownloadMenuItemsProps {
|
||||
activeVersion: Version | null | undefined;
|
||||
videoCanDownload: boolean;
|
||||
isDownloading: boolean;
|
||||
activeDownloadTarget: DownloadTarget | null;
|
||||
onDownload: (preference?: BunnyDownloadPreference) => void;
|
||||
}
|
||||
|
||||
export const DownloadControls = memo(function DownloadControls({
|
||||
activeVersion,
|
||||
videoCanDownload,
|
||||
isDownloading,
|
||||
activeDownloadTarget,
|
||||
onDownload,
|
||||
compact = false,
|
||||
}: DownloadControlsProps) {
|
||||
if (!activeVersion) return null;
|
||||
|
||||
const isVideoDownloadAvailable = videoCanDownload
|
||||
&& (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
|
||||
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={compact ? 'icon' : 'sm'}
|
||||
className={cn(
|
||||
compact && 'h-8 w-8',
|
||||
'transition-opacity duration-300',
|
||||
isDownloading && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
disabled={!isVideoDownloadAvailable || isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className={cn('h-4 w-4', !compact && 'mr-1')} />
|
||||
)}
|
||||
{!compact && (
|
||||
<>
|
||||
Download
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onDownload('original');
|
||||
}}
|
||||
disabled={!isVideoDownloadAvailable || isDownloading}
|
||||
>
|
||||
{activeDownloadTarget === 'original' ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download Original
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onDownload('compressed');
|
||||
}}
|
||||
disabled={!isVideoDownloadAvailable || isDownloading}
|
||||
>
|
||||
{activeDownloadTarget === 'compressed' ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download Compressed
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-8 w-8 transition-opacity duration-300',
|
||||
isDownloading && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
disabled={!isVideoDownloadAvailable || isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onDownload();
|
||||
}}
|
||||
disabled={!isVideoDownloadAvailable || isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'transition-opacity duration-300',
|
||||
isDownloading && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
onClick={() => onDownload()}
|
||||
disabled={!isVideoDownloadAvailable || isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
export const DownloadMenuItems = memo(function DownloadMenuItems({
|
||||
activeVersion,
|
||||
videoCanDownload,
|
||||
isDownloading,
|
||||
activeDownloadTarget,
|
||||
onDownload,
|
||||
}: DownloadMenuItemsProps) {
|
||||
if (!activeVersion) return null;
|
||||
|
||||
const isVideoDownloadAvailable = videoCanDownload
|
||||
&& (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
|
||||
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onDownload('original');
|
||||
}}
|
||||
disabled={!isVideoDownloadAvailable || isDownloading}
|
||||
>
|
||||
{activeDownloadTarget === 'original' ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download Original
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onDownload('compressed');
|
||||
}}
|
||||
disabled={!isVideoDownloadAvailable || isDownloading}
|
||||
>
|
||||
{activeDownloadTarget === 'compressed' ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download Compressed
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onDownload();
|
||||
}}
|
||||
disabled={!isVideoDownloadAvailable || isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { User } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface GuestNameGateProps {
|
||||
guestName: string;
|
||||
setGuestName: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const GuestNameGate = memo(function GuestNameGate({
|
||||
guestName,
|
||||
setGuestName,
|
||||
onConfirm,
|
||||
}: GuestNameGateProps) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm mx-auto p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-4">
|
||||
<User className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold mb-1">Welcome to OpenFrame</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your name to view and comment on this video
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && guestName.trim()) {
|
||||
onConfirm();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!guestName.trim()}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center mt-4">
|
||||
Or{' '}
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
sign in
|
||||
</Link>{' '}
|
||||
for a full account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface UseCommentExportParams {
|
||||
activeVersionId: string | null;
|
||||
showResolved: boolean;
|
||||
}
|
||||
|
||||
export function useCommentExport({ activeVersionId, showResolved }: UseCommentExportParams) {
|
||||
const [isExportingCsv, setIsExportingCsv] = useState(false);
|
||||
const [isExportingPdf, setIsExportingPdf] = useState(false);
|
||||
|
||||
const exportComments = useCallback(
|
||||
async (format: 'csv' | 'pdf') => {
|
||||
if (!activeVersionId) return;
|
||||
|
||||
if (format === 'csv') {
|
||||
setIsExportingCsv(true);
|
||||
} else {
|
||||
setIsExportingPdf(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/versions/${activeVersionId}/comments/export?format=${format}&includeResolved=${showResolved}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let message = 'Failed to export comments';
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (typeof data?.error === 'string') {
|
||||
message = data.error;
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback message when response is not JSON.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get('content-disposition');
|
||||
const fallbackName = `comments.${format}`;
|
||||
const matched = disposition?.match(/filename="?([^"]+)"?/i);
|
||||
const filename = matched?.[1] || fallbackName;
|
||||
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
toast.success(`Comments exported as ${format.toUpperCase()}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export comments:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to export comments');
|
||||
} finally {
|
||||
if (format === 'csv') {
|
||||
setIsExportingCsv(false);
|
||||
} else {
|
||||
setIsExportingPdf(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeVersionId, showResolved]
|
||||
);
|
||||
|
||||
return {
|
||||
isExportingCsv,
|
||||
isExportingPdf,
|
||||
exportComments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useCommentMedia() {
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const [voiceProgress, setVoiceProgress] = useState(0);
|
||||
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
|
||||
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
|
||||
|
||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
const voiceRafRef = useRef<number | null>(null);
|
||||
const voiceKnownDurationRef = useRef<number>(0);
|
||||
|
||||
const stopVoiceTracking = useCallback(() => {
|
||||
if (voiceRafRef.current) {
|
||||
cancelAnimationFrame(voiceRafRef.current);
|
||||
voiceRafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startVoiceTracking = useCallback(() => {
|
||||
stopVoiceTracking();
|
||||
const tick = () => {
|
||||
const audio = audioPlayerRef.current;
|
||||
if (audio) {
|
||||
const dur = isFinite(audio.duration) && audio.duration > 0
|
||||
? audio.duration
|
||||
: voiceKnownDurationRef.current;
|
||||
if (dur > 0) {
|
||||
setVoiceProgress((audio.currentTime / dur) * 100);
|
||||
setVoiceCurrentTime(audio.currentTime);
|
||||
}
|
||||
}
|
||||
voiceRafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
voiceRafRef.current = requestAnimationFrame(tick);
|
||||
}, [stopVoiceTracking]);
|
||||
|
||||
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
|
||||
if (playingVoiceId === commentId) {
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
audioPlayerRef.current = null;
|
||||
}
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
}
|
||||
stopVoiceTracking();
|
||||
|
||||
voiceKnownDurationRef.current = knownDuration || 0;
|
||||
const audio = new Audio(voiceUrl);
|
||||
audio.playbackRate = voicePlaybackRate;
|
||||
audioPlayerRef.current = audio;
|
||||
setPlayingVoiceId(commentId);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
|
||||
audio.onplay = () => {
|
||||
startVoiceTracking();
|
||||
};
|
||||
|
||||
audio.onended = () => {
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
audioPlayerRef.current = null;
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
audioPlayerRef.current = null;
|
||||
};
|
||||
|
||||
void audio.play();
|
||||
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
|
||||
|
||||
const toggleVoiceSpeed = useCallback(() => {
|
||||
setVoicePlaybackRate((prev) => {
|
||||
const next = prev === 1 ? 2 : 1;
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.playbackRate = next;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
audioPlayerRef.current = null;
|
||||
}
|
||||
stopVoiceTracking();
|
||||
};
|
||||
}, [stopVoiceTracking]);
|
||||
|
||||
return {
|
||||
playingVoiceId,
|
||||
voiceProgress,
|
||||
voiceCurrentTime,
|
||||
voicePlaybackRate,
|
||||
playVoice,
|
||||
toggleVoiceSpeed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
|
||||
|
||||
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
|
||||
function sanitizeDownloadFileName(value: string): string {
|
||||
return value
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getAllowedHosts() {
|
||||
return [
|
||||
BUNNY_PULL_ZONE_HOSTNAME,
|
||||
...(process.env.NEXT_PUBLIC_BUNNY_CDN_URL
|
||||
? (() => {
|
||||
try {
|
||||
return [new URL(process.env.NEXT_PUBLIC_BUNNY_CDN_URL).hostname];
|
||||
} catch {
|
||||
return [process.env.NEXT_PUBLIC_BUNNY_CDN_URL.replace(/^https?:\/\//, '').replace(/\/+$/, '')];
|
||||
}
|
||||
})()
|
||||
: []),
|
||||
...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','),
|
||||
]
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getSafeDirectDownloadUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedHosts = getAllowedHosts();
|
||||
if (allowedHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedHost = parsed.hostname.toLowerCase();
|
||||
if (!allowedHosts.includes(normalizedHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface UseDownloadActionsParams {
|
||||
activeVersion: (Version & { comments: Comment[] }) | undefined;
|
||||
video: VideoData | null;
|
||||
}
|
||||
|
||||
export function useDownloadActions({ activeVersion, video }: UseDownloadActionsParams) {
|
||||
const [activeDownloadTarget, setActiveDownloadTarget] = useState<DownloadTarget | null>(null);
|
||||
const isDownloadingVideo = activeDownloadTarget !== null;
|
||||
|
||||
const startDownload = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => {
|
||||
if (!activeVersion || !video || isDownloadingVideo) return;
|
||||
if (!video.canDownload) {
|
||||
toast.error('Download is disabled for this shared link');
|
||||
return;
|
||||
}
|
||||
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
|
||||
toast.error('This video source does not support direct download');
|
||||
return;
|
||||
}
|
||||
|
||||
const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct';
|
||||
setActiveDownloadTarget(target);
|
||||
try {
|
||||
let downloadUrl: string | null = null;
|
||||
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
const prepareRes = await fetch(`/api/versions/${activeVersion.id}/download?source=${preference}&prepare=1`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!prepareRes.ok) {
|
||||
const prepareBody = await prepareRes.json().catch(() => null);
|
||||
const fallbackError = preference === 'original'
|
||||
? 'Original file is not available for this video'
|
||||
: 'Compressed file is not available for this video';
|
||||
const errorMessage = typeof prepareBody?.error === 'string'
|
||||
? prepareBody.error
|
||||
: fallbackError;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
|
||||
} else {
|
||||
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
|
||||
if (!downloadUrl) {
|
||||
throw new Error('Direct download URL is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw new Error('Missing download URL');
|
||||
}
|
||||
|
||||
const versionLabel = activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`;
|
||||
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
if (activeVersion.providerId === 'direct') {
|
||||
a.download = `${baseName}.mp4`;
|
||||
}
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch (error) {
|
||||
console.error('Failed to start video download:', error);
|
||||
if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
|
||||
toast.error('This direct download host is not allowed');
|
||||
} else if (error instanceof Error && error.message) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error('Failed to start download');
|
||||
}
|
||||
} finally {
|
||||
setActiveDownloadTarget(null);
|
||||
}
|
||||
}, [activeVersion, video, isDownloadingVideo]);
|
||||
|
||||
return {
|
||||
activeDownloadTarget,
|
||||
isDownloadingVideo,
|
||||
startDownload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import * as tus from 'tus-js-client';
|
||||
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
|
||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||
|
||||
interface UseVersionActionsParams extends VersionActionsConfig {
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
activeVersionId: string | null;
|
||||
setActiveVersionId: Dispatch<SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
export function useVersionActions({
|
||||
projectId,
|
||||
videoId,
|
||||
setVideo,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
}: UseVersionActionsParams) {
|
||||
const [showVersionDialog, setShowVersionDialog] = useState(false);
|
||||
const [newVersionUrl, setNewVersionUrl] = useState('');
|
||||
const [newVersionLabel, setNewVersionLabel] = useState('');
|
||||
const [newVersionSource, setNewVersionSource] = useState<VideoSource | null>(null);
|
||||
const [newVersionUrlError, setNewVersionUrlError] = useState('');
|
||||
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
||||
const [newVersionMode, setNewVersionMode] = useState<'url' | 'file'>('url');
|
||||
const [newVersionFile, setNewVersionFile] = useState<File | null>(null);
|
||||
const [newVersionUploadProgress, setNewVersionUploadProgress] = useState(0);
|
||||
const [newVersionUploadStatus, setNewVersionUploadStatus] = useState('');
|
||||
|
||||
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
|
||||
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
|
||||
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
|
||||
|
||||
const handleNewVersionUrlChange = (url: string) => {
|
||||
setNewVersionUrl(url);
|
||||
setNewVersionUrlError('');
|
||||
if (!url.trim()) {
|
||||
setNewVersionSource(null);
|
||||
return;
|
||||
}
|
||||
const source = parseVideoUrl(url);
|
||||
if (source) {
|
||||
setNewVersionSource(source);
|
||||
} else {
|
||||
setNewVersionSource(null);
|
||||
if (url.length > 10) setNewVersionUrlError('Unsupported URL');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
if (!projectId) return;
|
||||
setIsCreatingVersion(true);
|
||||
setNewVersionUploadStatus('');
|
||||
setNewVersionUploadProgress(0);
|
||||
let uploadedBunnyVideoId: string | null = null;
|
||||
let uploadedBunnyUploadToken: string | null = null;
|
||||
|
||||
try {
|
||||
let finalVideoUrl = '';
|
||||
let finalProviderId = '';
|
||||
let finalProviderVideoId = '';
|
||||
let finalThumbnailUrl: string | null = null;
|
||||
let finalDuration: number | null = null;
|
||||
|
||||
if (newVersionMode === 'url') {
|
||||
if (!newVersionSource) throw new Error('Invalid URL');
|
||||
const meta = await fetchVideoMetadata(newVersionSource);
|
||||
finalVideoUrl = newVersionSource.originalUrl;
|
||||
finalProviderId = newVersionSource.providerId;
|
||||
finalProviderVideoId = newVersionSource.videoId;
|
||||
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
||||
finalDuration = meta?.duration || null;
|
||||
} else {
|
||||
if (!newVersionFile) throw new Error('No file selected');
|
||||
let title = newVersionFile.name;
|
||||
if (newVersionLabel.trim()) {
|
||||
title = newVersionLabel.trim();
|
||||
} else {
|
||||
title = title.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||
const { data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
|
||||
uploadedBunnyVideoId = bunnyVideoId;
|
||||
uploadedBunnyUploadToken = uploadToken;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
setNewVersionUploadStatus('Uploading video...');
|
||||
const upload = new tus.Upload(newVersionFile, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: signature,
|
||||
AuthorizationExpire: expirationTime.toString(),
|
||||
VideoId: bunnyVideoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: newVersionFile.type,
|
||||
title,
|
||||
},
|
||||
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
||||
setNewVersionUploadProgress(Number(percentage));
|
||||
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setNewVersionUploadStatus('Processing video...');
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
upload.start();
|
||||
});
|
||||
|
||||
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
|
||||
finalProviderId = 'bunny';
|
||||
finalProviderVideoId = bunnyVideoId;
|
||||
finalThumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${bunnyVideoId}/thumbnail.jpg`;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoUrl: finalVideoUrl,
|
||||
providerId: finalProviderId,
|
||||
providerVideoId: finalProviderVideoId,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
versionLabel: newVersionLabel.trim() || null,
|
||||
thumbnailUrl: finalThumbnailUrl,
|
||||
duration: finalDuration,
|
||||
setActive: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Failed to create version');
|
||||
}
|
||||
|
||||
const versionData = await res.json();
|
||||
const newVersion = versionData.data;
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const updatedVersions = prev.versions.map((v) => ({ ...v, isActive: false }));
|
||||
updatedVersions.unshift({
|
||||
...newVersion,
|
||||
comments: [],
|
||||
});
|
||||
return { ...prev, versions: updatedVersions };
|
||||
});
|
||||
setActiveVersionId(newVersion.id);
|
||||
setShowVersionDialog(false);
|
||||
setNewVersionUrl('');
|
||||
setNewVersionLabel('');
|
||||
setNewVersionSource(null);
|
||||
setNewVersionFile(null);
|
||||
setNewVersionUploadStatus('');
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: uploadedBunnyVideoId, uploadToken: uploadedBunnyUploadToken }),
|
||||
}).catch((cleanupError) => {
|
||||
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
||||
});
|
||||
}
|
||||
console.error('Failed to create version:', errorObj);
|
||||
toast.error(errorObj.message || 'Failed to create version');
|
||||
} finally {
|
||||
setIsCreatingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteVersion = async () => {
|
||||
if (!versionToDelete || !projectId) return;
|
||||
setIsDeletingVersion(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/videos/${videoId}/versions/${versionToDelete}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (res.ok) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
return { ...prev, versions: remaining };
|
||||
});
|
||||
|
||||
if (activeVersionId === versionToDelete) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
if (remaining.length > 0) {
|
||||
setActiveVersionId(remaining[0].id);
|
||||
} else {
|
||||
setActiveVersionId(null);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
setShowDeleteVersionDialog(false);
|
||||
setVersionToDelete(null);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || 'Failed to delete version');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete version');
|
||||
} finally {
|
||||
setIsDeletingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showVersionDialog,
|
||||
setShowVersionDialog,
|
||||
newVersionUrl,
|
||||
newVersionLabel,
|
||||
setNewVersionLabel,
|
||||
newVersionSource,
|
||||
newVersionUrlError,
|
||||
isCreatingVersion,
|
||||
newVersionMode,
|
||||
setNewVersionMode,
|
||||
newVersionFile,
|
||||
setNewVersionFile,
|
||||
newVersionUploadProgress,
|
||||
newVersionUploadStatus,
|
||||
handleNewVersionUrlChange,
|
||||
handleCreateVersion,
|
||||
|
||||
showDeleteVersionDialog,
|
||||
setShowDeleteVersionDialog,
|
||||
versionToDelete,
|
||||
setVersionToDelete,
|
||||
isDeletingVersion,
|
||||
handleDeleteVersion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { VideoData } from '@/components/video-page/types';
|
||||
|
||||
interface UseVersionDurationSyncParams {
|
||||
videoDuration: number;
|
||||
activeVersionDuration?: number | null;
|
||||
activeVersionId: string | null;
|
||||
propProjectId?: string;
|
||||
videoId: string;
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
}
|
||||
|
||||
export function useVersionDurationSync({
|
||||
videoDuration,
|
||||
activeVersionDuration,
|
||||
activeVersionId,
|
||||
propProjectId,
|
||||
videoId,
|
||||
setVideo,
|
||||
}: UseVersionDurationSyncParams) {
|
||||
useEffect(() => {
|
||||
if (!videoDuration || !activeVersionId || !propProjectId) return;
|
||||
if (activeVersionDuration && activeVersionDuration > 0) return;
|
||||
|
||||
const roundedDuration = Math.round(videoDuration);
|
||||
fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ duration: roundedDuration }),
|
||||
}).catch(() => {
|
||||
// ignore save errors
|
||||
});
|
||||
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
versions: prev.versions.map((v) =>
|
||||
v.id === activeVersionId ? { ...v, duration: roundedDuration } : v
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [videoDuration, activeVersionDuration, activeVersionId, propProjectId, videoId, setVideo]);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Comment, CommentTag, Version, VideoData } from '@/components/video-page/types';
|
||||
|
||||
interface UseVideoPageDataParams {
|
||||
mode: 'dashboard' | 'watch';
|
||||
videoId: string;
|
||||
propProjectId?: string;
|
||||
}
|
||||
|
||||
export function useVideoPageData({
|
||||
mode,
|
||||
videoId,
|
||||
propProjectId,
|
||||
}: UseVideoPageDataParams) {
|
||||
const [video, setVideo] = useState<VideoData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
|
||||
const [availableTags, setAvailableTags] = useState<CommentTag[]>([]);
|
||||
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
|
||||
|
||||
const commentsEtagRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const apiBasePath = useMemo(() => {
|
||||
return mode === 'dashboard'
|
||||
? `/api/projects/${propProjectId}/videos/${videoId}?includeComments=false`
|
||||
: `/api/watch/${videoId}`;
|
||||
}, [mode, propProjectId, videoId]);
|
||||
|
||||
const projectId = propProjectId || video?.projectId;
|
||||
|
||||
const fetchVersionComments = useCallback(async (versionId: string, useEtag: boolean) => {
|
||||
const headers: HeadersInit = {};
|
||||
if (useEtag) {
|
||||
const etag = commentsEtagRef.current.get(versionId);
|
||||
if (etag) headers['If-None-Match'] = etag;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/versions/${versionId}/comments?includeResolved=true`, {
|
||||
cache: 'no-store',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (res.status === 304) return;
|
||||
if (!res.ok) return;
|
||||
|
||||
const etag = res.headers.get('etag');
|
||||
if (etag) commentsEtagRef.current.set(versionId, etag);
|
||||
|
||||
const payload = await res.json();
|
||||
const commentsList = payload?.data?.comments;
|
||||
if (!Array.isArray(commentsList)) return;
|
||||
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const totalComments = commentsList.reduce((sum: number, comment: Comment) => {
|
||||
return sum + 1 + (comment.replies?.length ?? 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
versions: prev.versions.map((version) => (
|
||||
version.id === versionId
|
||||
? { ...version, comments: commentsList, _count: { comments: totalComments } }
|
||||
: version
|
||||
)),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchVideo() {
|
||||
try {
|
||||
const res = await fetch(apiBasePath, { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
const errorText = mode === 'dashboard' ? await res.text() : '';
|
||||
setError(mode === 'dashboard'
|
||||
? `Failed to load video: ${res.status} ${errorText}`
|
||||
: 'Video not found or access denied'
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const response = await res.json();
|
||||
const rawData = response.data as Omit<VideoData, 'versions'> & {
|
||||
versions?: Array<Version & { comments?: Comment[] }>;
|
||||
};
|
||||
const normalizedData: VideoData = {
|
||||
...rawData,
|
||||
versions: (rawData.versions || []).map((version) => ({
|
||||
...version,
|
||||
comments: Array.isArray(version.comments) ? version.comments : [],
|
||||
})),
|
||||
};
|
||||
|
||||
setVideo(normalizedData);
|
||||
const active = normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0];
|
||||
if (active) setActiveVersionId(active.id);
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
setError('Failed to load video');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
void fetchVideo();
|
||||
}, [apiBasePath, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeVersionId) return;
|
||||
void fetchVersionComments(activeVersionId, true);
|
||||
}, [activeVersionId, fetchVersionComments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
async function fetchTags() {
|
||||
try {
|
||||
const query = videoId ? `?videoId=${encodeURIComponent(videoId)}` : '';
|
||||
const res = await fetch(`/api/projects/${projectId}/tags${query}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const tags = data.data || [];
|
||||
setAvailableTags(tags);
|
||||
if (tags.length > 0 && !selectedTagId) {
|
||||
setSelectedTagId(tags[0].id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
void fetchTags();
|
||||
}, [projectId, selectedTagId, videoId]);
|
||||
|
||||
return {
|
||||
video,
|
||||
setVideo,
|
||||
loading,
|
||||
error,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
availableTags,
|
||||
setAvailableTags,
|
||||
selectedTagId,
|
||||
setSelectedTagId,
|
||||
projectId,
|
||||
fetchVersionComments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
'use client';
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
import Hls, { type Level } from 'hls.js';
|
||||
import { toast } from 'sonner';
|
||||
import type { AnnotationStroke } from '@/components/annotation-canvas';
|
||||
import type {
|
||||
BunnyPlaybackState,
|
||||
BunnyQualityOption,
|
||||
PlayerAdapter,
|
||||
Version,
|
||||
} from '@/components/video-page/types';
|
||||
|
||||
interface UseVideoPlayerParams {
|
||||
activeVersion: Version | undefined;
|
||||
activeVersionId: string | null;
|
||||
activeProviderId: string | undefined;
|
||||
embedUrl: string;
|
||||
canInitializePlayer: boolean;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
bunnyViewportRef: RefObject<HTMLDivElement | null>;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
|
||||
formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string;
|
||||
speedOptions: number[];
|
||||
scheduleWatchProgressSaveRef: RefObject<(input: {
|
||||
progress: number;
|
||||
duration?: number;
|
||||
immediate?: boolean;
|
||||
force?: boolean;
|
||||
}) => void>;
|
||||
setViewingAnnotation: (strokes: AnnotationStroke[] | null) => void;
|
||||
}
|
||||
|
||||
export function useVideoPlayer({
|
||||
activeVersion,
|
||||
activeVersionId,
|
||||
activeProviderId,
|
||||
embedUrl,
|
||||
canInitializePlayer,
|
||||
iframeRef,
|
||||
videoRef,
|
||||
bunnyViewportRef,
|
||||
timelineRef,
|
||||
hlsRef,
|
||||
playerRef,
|
||||
formatBunnyQualityLabel,
|
||||
speedOptions,
|
||||
scheduleWatchProgressSaveRef,
|
||||
setViewingAnnotation,
|
||||
}: UseVideoPlayerParams) {
|
||||
const [isApiLoaded, setIsApiLoaded] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
|
||||
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
||||
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
|
||||
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
|
||||
const [cursorIdle, setCursorIdle] = useState(false);
|
||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isFullscreenMode, setIsFullscreenMode] = useState(false);
|
||||
const [showComments, setShowComments] = useState(true);
|
||||
const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
isDraggingRef.current = isDragging;
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewportEl = bunnyViewportRef.current;
|
||||
if (!viewportEl || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const updateFrameWidth = () => {
|
||||
const viewportWidth = viewportEl.clientWidth;
|
||||
const viewportHeight = viewportEl.clientHeight;
|
||||
if (viewportWidth <= 0 || viewportHeight <= 0) return;
|
||||
setBunnyPortraitFrameWidth(Math.min(viewportWidth, viewportHeight * (9 / 16)));
|
||||
};
|
||||
|
||||
updateFrameWidth();
|
||||
const observer = new ResizeObserver(updateFrameWidth);
|
||||
observer.observe(viewportEl);
|
||||
return () => observer.disconnect();
|
||||
}, [activeVersionId, bunnyViewportRef]);
|
||||
|
||||
const handleVideoMouseMove = useCallback(() => {
|
||||
setCursorIdle(false);
|
||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||
|
||||
const shouldHideControls = isFullscreenMode;
|
||||
|
||||
if (isPlaying || shouldHideControls) {
|
||||
cursorIdleTimerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, 1000);
|
||||
}
|
||||
}, [isFullscreenMode, isPlaying]);
|
||||
|
||||
const handleVideoMouseLeave = useCallback(() => {
|
||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||
setCursorIdle(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isApiLoaded) return;
|
||||
|
||||
if (window.YT) {
|
||||
setIsApiLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://www.youtube.com/iframe_api';
|
||||
const firstScriptTag = document.getElementsByTagName('script')[0];
|
||||
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
|
||||
|
||||
window.onYouTubeIframeAPIReady = () => {
|
||||
setIsApiLoaded(true);
|
||||
};
|
||||
}, [isApiLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canInitializePlayer) return;
|
||||
if (!activeProviderId) return;
|
||||
const isYoutube = activeProviderId === 'youtube';
|
||||
const isBunny = activeProviderId === 'bunny';
|
||||
|
||||
if (isYoutube && !isApiLoaded) return;
|
||||
if (!isYoutube && !isBunny) return;
|
||||
|
||||
setIsReady(false);
|
||||
setBunnyPlaybackState('none');
|
||||
setCurrentTime(0);
|
||||
setVideoDuration(0);
|
||||
setIsPlaying(false);
|
||||
setIsMuted(false);
|
||||
setPlaybackSpeed(1);
|
||||
setQualityOptions([]);
|
||||
setSelectedQualityLevel(-1);
|
||||
setIsBunnyPortraitSource(false);
|
||||
|
||||
if (playerRef.current) {
|
||||
try { playerRef.current.destroy(); } catch { /* ignore */ }
|
||||
playerRef.current = null;
|
||||
}
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (bunnyRetryTimerRef.current) {
|
||||
clearTimeout(bunnyRetryTimerRef.current);
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
|
||||
const initPlayer = () => {
|
||||
if (isYoutube) {
|
||||
if (!iframeRef.current) return;
|
||||
playerRef.current = new YT.Player(iframeRef.current, {
|
||||
events: {
|
||||
onReady: (event: YT.PlayerEvent) => {
|
||||
setIsReady(true);
|
||||
const dur = event.target.getDuration();
|
||||
if (dur > 0) setVideoDuration(dur);
|
||||
},
|
||||
onStateChange: (event: YT.OnStateChangeEvent) => {
|
||||
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
||||
|
||||
if (event.data === YT.PlayerState.PAUSED) {
|
||||
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
||||
const playerDuration = playerRef.current?.getDuration?.() || 0;
|
||||
scheduleWatchProgressSaveRef.current({
|
||||
progress: playerCurrentTime,
|
||||
duration: playerDuration,
|
||||
immediate: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.data === YT.PlayerState.PLAYING) {
|
||||
const dur = event.target.getDuration();
|
||||
if (dur > 0) setVideoDuration(dur);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (isBunny) {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
let cachedDuration = 0;
|
||||
let destroyed = false;
|
||||
let retryAttempt = 0;
|
||||
let usingHlsJs = false;
|
||||
let hlsInstance: Hls | null = null;
|
||||
const clearRetryTimer = () => {
|
||||
if (bunnyRetryTimerRef.current) {
|
||||
clearTimeout(bunnyRetryTimerRef.current);
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleRetry = (retryFn: () => void) => {
|
||||
clearRetryTimer();
|
||||
bunnyRetryTimerRef.current = setTimeout(() => {
|
||||
if (!destroyed) {
|
||||
retryFn();
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
const getRetryUrl = () => {
|
||||
retryAttempt += 1;
|
||||
const separator = embedUrl.includes('?') ? '&' : '?';
|
||||
return `${embedUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
|
||||
};
|
||||
const retryNativeLoad = () => {
|
||||
videoEl.src = getRetryUrl();
|
||||
videoEl.load();
|
||||
};
|
||||
const retryHlsLoad = () => {
|
||||
if (destroyed || !hlsInstance) return;
|
||||
const retryUrl = getRetryUrl();
|
||||
try {
|
||||
hlsInstance.stopLoad();
|
||||
} catch {
|
||||
// ignore stop-load failures and continue with a fresh loadSource
|
||||
}
|
||||
hlsInstance.loadSource(retryUrl);
|
||||
hlsInstance.startLoad(-1);
|
||||
};
|
||||
|
||||
const syncDuration = () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
setVideoDuration(videoEl.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProgress = () => {
|
||||
const current = videoEl.currentTime || 0;
|
||||
const duration = Number.isFinite(videoEl.duration) && videoEl.duration > 0 ? videoEl.duration : cachedDuration;
|
||||
scheduleWatchProgressSaveRef.current({
|
||||
progress: current,
|
||||
duration,
|
||||
immediate: true,
|
||||
force: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (destroyed) return;
|
||||
clearRetryTimer();
|
||||
setBunnyPlaybackState('none');
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
setIsReady(true);
|
||||
syncDuration();
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
setIsPlaying(true);
|
||||
setBunnyPlaybackState('none');
|
||||
syncDuration();
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
setIsPlaying(false);
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!isDraggingRef.current) {
|
||||
setCurrentTime(videoEl.currentTime || 0);
|
||||
}
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0 && videoEl.duration !== cachedDuration) {
|
||||
cachedDuration = videoEl.duration;
|
||||
setVideoDuration(videoEl.duration);
|
||||
}
|
||||
};
|
||||
const onVideoError = () => {
|
||||
if (destroyed) return;
|
||||
if (usingHlsJs) return;
|
||||
if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
setBunnyPlaybackState('error');
|
||||
return;
|
||||
}
|
||||
setIsReady(false);
|
||||
setBunnyPlaybackState('processing');
|
||||
scheduleRetry(retryNativeLoad);
|
||||
};
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
videoEl.addEventListener('pause', onPause);
|
||||
videoEl.addEventListener('ended', onEnded);
|
||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.addEventListener('error', onVideoError);
|
||||
|
||||
const configureHlsLevels = (levels: Level[]) => {
|
||||
setQualityOptions(levels.map((level, index) => ({
|
||||
level: index,
|
||||
label: formatBunnyQualityLabel(level, index),
|
||||
})));
|
||||
setSelectedQualityLevel(-1);
|
||||
};
|
||||
|
||||
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoEl.src = embedUrl;
|
||||
videoEl.load();
|
||||
} else if (Hls.isSupported()) {
|
||||
usingHlsJs = true;
|
||||
const hls = new Hls();
|
||||
hlsInstance = hls;
|
||||
hlsRef.current = hls;
|
||||
hls.attachMedia(videoEl);
|
||||
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
if (!destroyed) {
|
||||
hls.loadSource(embedUrl);
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
if (destroyed) return;
|
||||
clearRetryTimer();
|
||||
setBunnyPlaybackState('none');
|
||||
configureHlsLevels(data.levels);
|
||||
setIsReady(true);
|
||||
syncDuration();
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (destroyed) return;
|
||||
const responseCode = (data as { response?: { code?: number } }).response?.code;
|
||||
const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR
|
||||
|| data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
||||
const hasProcessingLikeStatus = responseCode === undefined
|
||||
|| responseCode === 0
|
||||
|| responseCode === 403
|
||||
|| responseCode === 404
|
||||
|| responseCode === 423
|
||||
|| responseCode === 429
|
||||
|| responseCode === 503;
|
||||
const isLikelyProcessing = isManifestLoadFailure
|
||||
&& hasProcessingLikeStatus;
|
||||
const isNetworkPreMetadataProcessing = data.type === Hls.ErrorTypes.NETWORK_ERROR
|
||||
&& hasProcessingLikeStatus
|
||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
const isUnknownPreMetadataProcessing = !data.details
|
||||
&& !data.type
|
||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
|
||||
setIsReady(false);
|
||||
setBunnyPlaybackState('processing');
|
||||
scheduleRetry(retryHlsLoad);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.fatal) {
|
||||
setBunnyPlaybackState('error');
|
||||
console.error('Fatal HLS error:', data);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setBunnyPlaybackState('error');
|
||||
console.error('HLS is not supported in this browser.');
|
||||
}
|
||||
|
||||
playerRef.current = {
|
||||
playVideo: () => {
|
||||
videoEl.play().catch((err) => console.error('Error playing Bunny video:', err));
|
||||
},
|
||||
pauseVideo: () => videoEl.pause(),
|
||||
seekTo: (time: number) => {
|
||||
videoEl.currentTime = time;
|
||||
},
|
||||
mute: () => {
|
||||
videoEl.muted = true;
|
||||
},
|
||||
unMute: () => {
|
||||
videoEl.muted = false;
|
||||
},
|
||||
isMuted: () => videoEl.muted,
|
||||
getCurrentTime: () => videoEl.currentTime || 0,
|
||||
getDuration: () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () => (
|
||||
videoEl.paused
|
||||
? (window.YT?.PlayerState?.PAUSED ?? 2)
|
||||
: (window.YT?.PlayerState?.PLAYING ?? 1)
|
||||
),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
clearRetryTimer();
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
videoEl.removeEventListener('ended', onEnded);
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.removeEventListener('error', onVideoError);
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
hlsRef.current = null;
|
||||
}
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (isYoutube) {
|
||||
if (window.YT?.Player) {
|
||||
initPlayer();
|
||||
} else {
|
||||
window.onYouTubeIframeAPIReady = initPlayer;
|
||||
}
|
||||
} else if (isBunny) {
|
||||
initPlayer();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (isYoutube) {
|
||||
window.onYouTubeIframeAPIReady = undefined;
|
||||
}
|
||||
if (playerRef.current) {
|
||||
try { playerRef.current.destroy(); } catch { /* ignore */ }
|
||||
playerRef.current = null;
|
||||
}
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (bunnyRetryTimerRef.current) {
|
||||
clearTimeout(bunnyRetryTimerRef.current);
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().then(() => {
|
||||
setIsFullscreenMode(true);
|
||||
setShowComments(false);
|
||||
}).catch((err) => {
|
||||
console.error('Fullscreen failed:', err);
|
||||
toast.error('Unable to enter fullscreen mode');
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => {
|
||||
setIsFullscreenMode(false);
|
||||
setShowComments(true);
|
||||
}).catch((err) => {
|
||||
console.error('Exit fullscreen failed:', err);
|
||||
toast.error('Unable to exit fullscreen mode');
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
const isCurrentlyFullscreen = !!document.fullscreenElement;
|
||||
setIsFullscreenMode(isCurrentlyFullscreen);
|
||||
if (isCurrentlyFullscreen) {
|
||||
setShowComments(false);
|
||||
} else {
|
||||
setShowComments(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !playerRef.current) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!isDragging && playerRef.current) {
|
||||
if (playerRef.current.getCurrentTime) {
|
||||
setCurrentTime(playerRef.current.getCurrentTime());
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isReady, isDragging, activeVersion?.providerId, playerRef]);
|
||||
|
||||
const duration = useMemo(() => {
|
||||
return videoDuration || activeVersion?.duration || 0;
|
||||
}, [videoDuration, activeVersion?.duration]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
const isBunnyBlocked = activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none';
|
||||
const isPlaybackControlKey = [
|
||||
'Space',
|
||||
'KeyK',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'Comma',
|
||||
'Period',
|
||||
'KeyM',
|
||||
'KeyJ',
|
||||
'KeyL',
|
||||
].includes(e.code);
|
||||
if (isBunnyBlocked && isPlaybackControlKey) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
case 'KeyK':
|
||||
e.preventDefault();
|
||||
if (playerRef.current) {
|
||||
if (isPlaying) {
|
||||
playerRef.current.pauseVideo();
|
||||
} else {
|
||||
playerRef.current.playVideo();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (playerRef.current) {
|
||||
const newTime = Math.max(0, currentTime - 5);
|
||||
if (playerRef.current.seekTo) {
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
}
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (playerRef.current) {
|
||||
const newTime = Math.min(duration, currentTime + 5);
|
||||
if (playerRef.current.seekTo) {
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
}
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
{
|
||||
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
||||
if (currentIndex < speedOptions.length - 1) {
|
||||
const newSpeed = speedOptions[currentIndex + 1];
|
||||
setPlaybackSpeed(newSpeed);
|
||||
playerRef.current?.setPlaybackRate(newSpeed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
{
|
||||
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
||||
if (currentIndex > 0) {
|
||||
const newSpeed = speedOptions[currentIndex - 1];
|
||||
setPlaybackSpeed(newSpeed);
|
||||
playerRef.current?.setPlaybackRate(newSpeed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Comma':
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
||||
if (currentIndex > 0) {
|
||||
const newSpeed = speedOptions[currentIndex - 1];
|
||||
setPlaybackSpeed(newSpeed);
|
||||
playerRef.current?.setPlaybackRate(newSpeed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Period':
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
||||
if (currentIndex < speedOptions.length - 1) {
|
||||
const newSpeed = speedOptions[currentIndex + 1];
|
||||
setPlaybackSpeed(newSpeed);
|
||||
playerRef.current?.setPlaybackRate(newSpeed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'KeyM':
|
||||
e.preventDefault();
|
||||
if (playerRef.current) {
|
||||
if (isMuted) {
|
||||
playerRef.current.unMute();
|
||||
} else {
|
||||
playerRef.current.mute();
|
||||
}
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
break;
|
||||
case 'KeyJ':
|
||||
e.preventDefault();
|
||||
if (playerRef.current?.seekTo) {
|
||||
const newTime = Math.max(0, currentTime - 10);
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'KeyL':
|
||||
e.preventDefault();
|
||||
if (playerRef.current?.seekTo) {
|
||||
const newTime = Math.min(duration, currentTime + 10);
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'KeyF':
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none') return;
|
||||
if (!playerRef.current) return;
|
||||
if (isPlaying) {
|
||||
playerRef.current.pauseVideo();
|
||||
} else {
|
||||
playerRef.current.playVideo();
|
||||
}
|
||||
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, playerRef]);
|
||||
|
||||
const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => {
|
||||
setCurrentTime(timestamp);
|
||||
if (playerRef.current?.seekTo) {
|
||||
const playerState = playerRef.current.getPlayerState?.();
|
||||
const ytPlayingState = window.YT?.PlayerState?.PLAYING ?? 1;
|
||||
const ytBufferingState = window.YT?.PlayerState?.BUFFERING ?? 3;
|
||||
const wasPlayingBeforeSeek = typeof playerState === 'number'
|
||||
? playerState === ytPlayingState || playerState === ytBufferingState
|
||||
: isPlaying;
|
||||
|
||||
playerRef.current.seekTo(timestamp, true);
|
||||
if (wasPlayingBeforeSeek) {
|
||||
playerRef.current.playVideo();
|
||||
} else {
|
||||
playerRef.current.pauseVideo();
|
||||
}
|
||||
}
|
||||
if (annotation) {
|
||||
try {
|
||||
const strokes = JSON.parse(annotation) as AnnotationStroke[];
|
||||
setViewingAnnotation(strokes);
|
||||
} catch {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
} else {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
}, [isPlaying, playerRef, setViewingAnnotation]);
|
||||
|
||||
const handleMuteToggle = useCallback(() => {
|
||||
if (!playerRef.current) return;
|
||||
if (isMuted) {
|
||||
playerRef.current.unMute();
|
||||
} else {
|
||||
playerRef.current.mute();
|
||||
}
|
||||
setIsMuted(!isMuted);
|
||||
}, [isMuted, playerRef]);
|
||||
|
||||
const handleSkip = useCallback(
|
||||
(seconds: number) => {
|
||||
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
||||
handleSeekToTimestamp(newTime);
|
||||
},
|
||||
[currentTime, duration, handleSeekToTimestamp]
|
||||
);
|
||||
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
setPlaybackSpeed(speed);
|
||||
playerRef.current?.setPlaybackRate(speed);
|
||||
},
|
||||
[playerRef]
|
||||
);
|
||||
|
||||
const handleQualityChange = useCallback((level: number) => {
|
||||
const hls = hlsRef.current;
|
||||
if (!hls) return;
|
||||
|
||||
if (level === -1) {
|
||||
hls.currentLevel = -1;
|
||||
hls.nextLevel = -1;
|
||||
setSelectedQualityLevel(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
hls.currentLevel = level;
|
||||
hls.nextLevel = level;
|
||||
setSelectedQualityLevel(level);
|
||||
}, [hlsRef]);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!timelineRef.current) return;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||
const newTime = percentage * duration;
|
||||
handleSeekToTimestamp(newTime);
|
||||
},
|
||||
[duration, handleSeekToTimestamp, timelineRef]
|
||||
);
|
||||
|
||||
const handleTimelineMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true);
|
||||
handleTimelineClick(e);
|
||||
},
|
||||
[handleTimelineClick]
|
||||
);
|
||||
|
||||
const handleTimelineMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !timelineRef.current) return;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||
setCurrentTime(percentage * duration);
|
||||
},
|
||||
[isDragging, duration, timelineRef]
|
||||
);
|
||||
|
||||
const handleTimelineMouseUp = useCallback(() => {
|
||||
if (isDragging) {
|
||||
handleSeekToTimestamp(currentTime);
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, [isDragging, currentTime, handleSeekToTimestamp]);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
bunnyPlaybackState,
|
||||
currentTime,
|
||||
setCurrentTime,
|
||||
videoDuration,
|
||||
setVideoDuration,
|
||||
isPlaying,
|
||||
isMuted,
|
||||
isDragging,
|
||||
playbackSpeed,
|
||||
qualityOptions,
|
||||
selectedQualityLevel,
|
||||
isBunnyPortraitSource,
|
||||
bunnyPortraitFrameWidth,
|
||||
cursorIdle,
|
||||
isFullscreenMode,
|
||||
showComments,
|
||||
isMobileCommentsOpen,
|
||||
setShowComments,
|
||||
setIsMobileCommentsOpen,
|
||||
handleVideoMouseMove,
|
||||
handleVideoMouseLeave,
|
||||
handlePlayPause,
|
||||
handleSeekToTimestamp,
|
||||
handleMuteToggle,
|
||||
handleSkip,
|
||||
handleSpeedChange,
|
||||
handleQualityChange,
|
||||
handleTimelineMouseDown,
|
||||
handleTimelineMouseMove,
|
||||
handleTimelineMouseUp,
|
||||
toggleFullscreen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import type { PlayerAdapter, WatchProgressConfig } from '@/components/video-page/types';
|
||||
|
||||
interface UseWatchProgressParams extends WatchProgressConfig {
|
||||
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
|
||||
isReady: boolean;
|
||||
currentTime: number;
|
||||
videoDuration: number;
|
||||
}
|
||||
|
||||
export function useWatchProgress({
|
||||
videoId,
|
||||
activeVersionId,
|
||||
isAuthenticated,
|
||||
pathname,
|
||||
playerRef,
|
||||
isReady,
|
||||
currentTime,
|
||||
videoDuration,
|
||||
}: UseWatchProgressParams) {
|
||||
const [savedProgress, setSavedProgress] = useState<number | null>(null);
|
||||
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||
const [progressFetchKey, setProgressFetchKey] = useState(0);
|
||||
|
||||
const videoDurationRef = useRef(0);
|
||||
const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const progressDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const progressWriteInFlightRef = useRef(false);
|
||||
const pendingProgressPayloadRef = useRef<{ progress: number; duration: number; force: boolean } | null>(null);
|
||||
const lastSavedProgressRef = useRef<number>(0);
|
||||
const lastPathnameRef = useRef<string>(pathname);
|
||||
|
||||
const flushScheduledWatchProgress = useCallback(async () => {
|
||||
if (!isAuthenticated || !activeVersionId || progressWriteInFlightRef.current) return;
|
||||
|
||||
const nextPayload = pendingProgressPayloadRef.current;
|
||||
if (!nextPayload) return;
|
||||
|
||||
if (!nextPayload.force && Math.abs(nextPayload.progress - lastSavedProgressRef.current) < 2) {
|
||||
pendingProgressPayloadRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingProgressPayloadRef.current = null;
|
||||
progressWriteInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/watch/${videoId}/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
progress: nextPayload.progress,
|
||||
duration: nextPayload.duration,
|
||||
versionId: activeVersionId,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
lastSavedProgressRef.current = nextPayload.progress;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving watch progress:', err);
|
||||
} finally {
|
||||
progressWriteInFlightRef.current = false;
|
||||
if (pendingProgressPayloadRef.current) {
|
||||
void flushScheduledWatchProgress();
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, activeVersionId, videoId]);
|
||||
|
||||
const scheduleWatchProgressSave = useCallback((input: {
|
||||
progress: number;
|
||||
duration?: number;
|
||||
immediate?: boolean;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
if (!isAuthenticated || !activeVersionId) return;
|
||||
|
||||
const progress = Math.max(0, input.progress);
|
||||
if (progress <= 0) return;
|
||||
|
||||
const duration = Math.max(0, input.duration ?? videoDurationRef.current ?? 0);
|
||||
const force = input.force ?? false;
|
||||
|
||||
if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPayload = pendingProgressPayloadRef.current;
|
||||
pendingProgressPayloadRef.current = existingPayload
|
||||
? {
|
||||
progress: Math.max(existingPayload.progress, progress),
|
||||
duration: Math.max(existingPayload.duration, duration),
|
||||
force: existingPayload.force || force,
|
||||
}
|
||||
: { progress, duration, force };
|
||||
|
||||
if (input.immediate) {
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
progressDebounceTimerRef.current = null;
|
||||
}
|
||||
void flushScheduledWatchProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
}
|
||||
|
||||
progressDebounceTimerRef.current = setTimeout(() => {
|
||||
progressDebounceTimerRef.current = null;
|
||||
void flushScheduledWatchProgress();
|
||||
}, 800);
|
||||
}, [isAuthenticated, activeVersionId, flushScheduledWatchProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
progressDebounceTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
videoDurationRef.current = videoDuration;
|
||||
}, [videoDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
lastSavedProgressRef.current = 0;
|
||||
pendingProgressPayloadRef.current = null;
|
||||
progressWriteInFlightRef.current = false;
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
progressDebounceTimerRef.current = null;
|
||||
}
|
||||
}, [videoId, activeVersionId]);
|
||||
|
||||
const loadWatchProgress = useCallback(async (showPrompt = true) => {
|
||||
if (!isAuthenticated || !activeVersionId) return;
|
||||
|
||||
setSavedProgress(null);
|
||||
setShowResumePrompt(false);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const response = await res.json();
|
||||
const progress = response.data?.progress || 0;
|
||||
const percentage = response.data?.percentage || 0;
|
||||
|
||||
if (showPrompt && percentage > 5 && percentage < 95) {
|
||||
setSavedProgress(progress);
|
||||
setShowResumePrompt(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading watch progress:', err);
|
||||
}
|
||||
}, [isAuthenticated, activeVersionId, videoId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWatchProgress();
|
||||
}, [loadWatchProgress, progressFetchKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastPathnameRef.current !== pathname) {
|
||||
const previousPath = lastPathnameRef.current;
|
||||
lastPathnameRef.current = pathname;
|
||||
|
||||
if (previousPath !== pathname) {
|
||||
setProgressFetchKey((k) => k + 1);
|
||||
}
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !isReady || !activeVersionId) return;
|
||||
|
||||
progressSaveTimerRef.current = setInterval(() => {
|
||||
if (playerRef.current?.getCurrentTime) {
|
||||
scheduleWatchProgressSave({
|
||||
progress: playerRef.current.getCurrentTime(),
|
||||
duration: playerRef.current.getDuration?.() || videoDuration,
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (progressSaveTimerRef.current) {
|
||||
clearInterval(progressSaveTimerRef.current);
|
||||
progressSaveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, isReady, videoDuration, activeVersionId, scheduleWatchProgressSave, playerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const saveProgressOnLeave = () => {
|
||||
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || currentTime;
|
||||
const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
|
||||
const pendingPayload = pendingProgressPayloadRef.current;
|
||||
const finalProgress = Math.max(playerCurrentTime, pendingPayload?.progress ?? 0);
|
||||
const finalDuration = Math.max(playerDuration, pendingPayload?.duration ?? 0);
|
||||
|
||||
if (finalProgress > 0 && navigator.sendBeacon && activeVersionId) {
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
progressDebounceTimerRef.current = null;
|
||||
}
|
||||
const data = new Blob([JSON.stringify({
|
||||
progress: finalProgress,
|
||||
duration: finalDuration,
|
||||
versionId: activeVersionId,
|
||||
})], { type: 'application/json' });
|
||||
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
||||
const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
|
||||
|
||||
if (document.visibilityState === 'hidden') {
|
||||
scheduleWatchProgressSave({
|
||||
progress: playerCurrentTime,
|
||||
duration: playerDuration,
|
||||
immediate: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', saveProgressOnLeave);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', saveProgressOnLeave);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [isAuthenticated, currentTime, videoDuration, activeVersionId, videoId, scheduleWatchProgressSave, playerRef]);
|
||||
|
||||
const handleResumeFromSaved = useCallback(() => {
|
||||
if (savedProgress !== null && playerRef.current) {
|
||||
if (playerRef.current.seekTo) {
|
||||
playerRef.current.seekTo(savedProgress, true);
|
||||
}
|
||||
setShowResumePrompt(false);
|
||||
setSavedProgress(null);
|
||||
return savedProgress;
|
||||
}
|
||||
return null;
|
||||
}, [savedProgress, playerRef]);
|
||||
|
||||
const handleDismissResume = useCallback(() => {
|
||||
setShowResumePrompt(false);
|
||||
setSavedProgress(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
savedProgress,
|
||||
showResumePrompt,
|
||||
scheduleWatchProgressSave,
|
||||
loadWatchProgress,
|
||||
handleResumeFromSaved,
|
||||
handleDismissResume,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
interface ImagePreviewDialogProps {
|
||||
previewImage: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ImagePreviewDialog = memo(function ImagePreviewDialog({
|
||||
previewImage,
|
||||
onClose,
|
||||
}: ImagePreviewDialogProps) {
|
||||
return (
|
||||
<Dialog open={!!previewImage} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex flex-col items-center justify-center rounded-none"
|
||||
>
|
||||
<DialogTitle className="sr-only">Image Preview</DialogTitle>
|
||||
<div className="absolute top-4 right-4 flex gap-3 z-50">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (!previewImage) return;
|
||||
const response = await fetch(previewImage);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = previewImage.split('/').pop() || 'attachment.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error);
|
||||
toast.error('Failed to download image');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="relative w-full h-full flex items-center justify-center p-4 cursor-zoom-out"
|
||||
onClick={onClose}
|
||||
>
|
||||
{previewImage && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
className="max-w-[95vw] max-h-[90vh] object-contain rounded-md select-none cursor-default"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
'use client';
|
||||
|
||||
import { memo, type RefObject } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Gauge,
|
||||
Maximize,
|
||||
MessageSquare,
|
||||
MessageSquareOff,
|
||||
Minimize,
|
||||
Pause,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnnotationCanvas, type AnnotationCanvasHandle, type AnnotationStroke } from '@/components/annotation-canvas';
|
||||
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
|
||||
|
||||
interface PlayerCoreProps {
|
||||
activeVersionId: string | null;
|
||||
activeProviderId: string | undefined;
|
||||
embedUrl: string;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
bunnyViewportRef: RefObject<HTMLDivElement | null>;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
videoContainerRef: RefObject<HTMLDivElement | null>;
|
||||
isFullscreenMode: boolean;
|
||||
cursorIdle: boolean;
|
||||
isPlaying: boolean;
|
||||
handlePlayPause: () => void;
|
||||
handleVideoMouseMove: () => void;
|
||||
handleVideoMouseLeave: () => void;
|
||||
isBunnyPortraitSource: boolean;
|
||||
bunnyPortraitFrameWidth: number;
|
||||
showBunnyProcessingOverlay: boolean;
|
||||
showBunnyErrorOverlay: boolean;
|
||||
showResumePrompt: boolean;
|
||||
savedProgress: number | null;
|
||||
formatTime: (value: number) => string;
|
||||
handleResumeFromSaved: () => void;
|
||||
handleDismissResume: () => void;
|
||||
isAnnotating: boolean;
|
||||
annotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
|
||||
setAnnotationStrokes: (strokes: AnnotationStroke[] | null) => void;
|
||||
setIsAnnotating: (value: boolean) => void;
|
||||
setViewingAnnotation: (strokes: AnnotationStroke[] | null) => void;
|
||||
viewingAnnotation: AnnotationStroke[] | null;
|
||||
isEditingAnnotation: boolean;
|
||||
editAnnotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
|
||||
editAnnotationInitialStrokes?: AnnotationStroke[];
|
||||
setEditAnnotationData: (value: string | null | undefined) => void;
|
||||
setIsEditingAnnotation: (value: boolean) => void;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
handleSkip: (seconds: number) => void;
|
||||
handleMuteToggle: () => void;
|
||||
isMuted: boolean;
|
||||
selectedQualityLabel: string;
|
||||
selectedQualityLevel: number;
|
||||
qualityOptions: BunnyQualityOption[];
|
||||
handleQualityChange: (level: number) => void;
|
||||
playbackSpeed: number;
|
||||
speedOptions: number[];
|
||||
handleSpeedChange: (speed: number) => void;
|
||||
toggleFullscreen: () => void;
|
||||
showComments: boolean;
|
||||
setShowComments: (value: boolean) => void;
|
||||
setIsMobileCommentsOpen: (value: boolean) => void;
|
||||
handleTimelineMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
handleTimelineMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
handleSeekToTimestamp: (timestamp: number, annotation?: string | null) => void;
|
||||
commentMarkers: CommentMarker[];
|
||||
}
|
||||
|
||||
export const PlayerCore = memo(function PlayerCore({
|
||||
activeVersionId,
|
||||
activeProviderId,
|
||||
embedUrl,
|
||||
videoRef,
|
||||
iframeRef,
|
||||
bunnyViewportRef,
|
||||
timelineRef,
|
||||
videoContainerRef,
|
||||
isFullscreenMode,
|
||||
cursorIdle,
|
||||
isPlaying,
|
||||
handlePlayPause,
|
||||
handleVideoMouseMove,
|
||||
handleVideoMouseLeave,
|
||||
isBunnyPortraitSource,
|
||||
bunnyPortraitFrameWidth,
|
||||
showBunnyProcessingOverlay,
|
||||
showBunnyErrorOverlay,
|
||||
showResumePrompt,
|
||||
savedProgress,
|
||||
formatTime,
|
||||
handleResumeFromSaved,
|
||||
handleDismissResume,
|
||||
isAnnotating,
|
||||
annotationCanvasRef,
|
||||
setAnnotationStrokes,
|
||||
setIsAnnotating,
|
||||
setViewingAnnotation,
|
||||
viewingAnnotation,
|
||||
isEditingAnnotation,
|
||||
editAnnotationCanvasRef,
|
||||
editAnnotationInitialStrokes,
|
||||
setEditAnnotationData,
|
||||
setIsEditingAnnotation,
|
||||
currentTime,
|
||||
duration,
|
||||
handleSkip,
|
||||
handleMuteToggle,
|
||||
isMuted,
|
||||
selectedQualityLabel,
|
||||
selectedQualityLevel,
|
||||
qualityOptions,
|
||||
handleQualityChange,
|
||||
playbackSpeed,
|
||||
speedOptions,
|
||||
handleSpeedChange,
|
||||
toggleFullscreen,
|
||||
showComments,
|
||||
setShowComments,
|
||||
setIsMobileCommentsOpen,
|
||||
handleTimelineMouseDown,
|
||||
handleTimelineMouseMove,
|
||||
handleSeekToTimestamp,
|
||||
commentMarkers,
|
||||
}: PlayerCoreProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={videoContainerRef}
|
||||
className={cn(
|
||||
'flex-1 bg-black flex items-center justify-center relative cursor-pointer group min-h-0',
|
||||
isFullscreenMode && 'absolute inset-0',
|
||||
cursorIdle && isPlaying && 'cursor-none'
|
||||
)}
|
||||
onClick={handlePlayPause}
|
||||
onMouseMove={handleVideoMouseMove}
|
||||
onMouseLeave={handleVideoMouseLeave}
|
||||
>
|
||||
<div className={cn('relative w-full h-full', isFullscreenMode && 'absolute inset-0')}>
|
||||
{activeProviderId === 'bunny' ? (
|
||||
<div ref={bunnyViewportRef} className="absolute inset-0 flex items-center justify-center bg-black">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center bg-black',
|
||||
isBunnyPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
|
||||
)}
|
||||
style={isBunnyPortraitSource && bunnyPortraitFrameWidth > 0 ? { width: `${bunnyPortraitFrameWidth}px` } : undefined}
|
||||
>
|
||||
<video
|
||||
key={activeVersionId}
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain border-0 bg-black"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
backgroundColor: 'black',
|
||||
}}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
key={activeVersionId}
|
||||
ref={iframeRef}
|
||||
src={embedUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
referrerPolicy="origin-when-cross-origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300',
|
||||
(showBunnyProcessingOverlay || showBunnyErrorOverlay) && 'opacity-0 pointer-events-none',
|
||||
isPlaying
|
||||
? cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center relative z-10">
|
||||
{isPlaying ? (
|
||||
<Pause className="h-8 w-8 text-white relative right-[-1px]" />
|
||||
) : (
|
||||
<Play className="h-8 w-8 text-white relative left-[2px]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showBunnyProcessingOverlay && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/65">
|
||||
<div className="max-w-sm rounded-md border bg-background/95 px-4 py-3 text-center shadow-lg">
|
||||
<div className="mb-2 flex items-center justify-center gap-2 text-sm font-medium">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Video Is Processing
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This video is still processing. We'll keep retrying every few seconds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBunnyErrorOverlay && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/65">
|
||||
<div className="max-w-sm rounded-md border bg-background/95 px-4 py-3 text-center shadow-lg">
|
||||
<div className="mb-2 flex items-center justify-center gap-2 text-sm font-medium">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
Unable To Load Video
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Bunny stream is unavailable right now. Please refresh this page in a moment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showResumePrompt && savedProgress !== null && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-10">
|
||||
<div className="bg-background/95 backdrop-blur-sm rounded-lg p-4 shadow-lg max-w-sm mx-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Continue watching?</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Resume from {formatTime(savedProgress)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleResumeFromSaved}
|
||||
className="flex-1"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDismissResume}
|
||||
className="flex-1"
|
||||
>
|
||||
Start over
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAnnotating && (
|
||||
<AnnotationCanvas
|
||||
ref={annotationCanvasRef}
|
||||
mode="draw"
|
||||
onConfirm={(strokes) => {
|
||||
setAnnotationStrokes(strokes);
|
||||
setIsAnnotating(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsAnnotating(false);
|
||||
setAnnotationStrokes(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewingAnnotation && !isAnnotating && !isEditingAnnotation && (
|
||||
<AnnotationCanvas
|
||||
mode="view"
|
||||
strokes={viewingAnnotation}
|
||||
onDismiss={() => setViewingAnnotation(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditingAnnotation && (
|
||||
<AnnotationCanvas
|
||||
ref={editAnnotationCanvasRef}
|
||||
mode="draw"
|
||||
strokes={editAnnotationInitialStrokes}
|
||||
onConfirm={(strokes) => {
|
||||
setEditAnnotationData(JSON.stringify(strokes));
|
||||
setIsEditingAnnotation(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsEditingAnnotation(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'shrink-0 px-4 py-2 bg-background border-t',
|
||||
isFullscreenMode ? 'absolute bottom-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
|
||||
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
|
||||
)}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePlayPause}>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleSkip(-10)}
|
||||
title="Back 10s"
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleSkip(10)}
|
||||
title="Forward 10s"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleMuteToggle}
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground ml-1 tabular-nums">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center">
|
||||
{activeProviderId === 'bunny' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
|
||||
Quality {selectedQualityLabel}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[120px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleQualityChange(-1)}
|
||||
className={cn(selectedQualityLevel === -1 && 'font-bold text-primary')}
|
||||
>
|
||||
Auto
|
||||
</DropdownMenuItem>
|
||||
{qualityOptions.length > 0 && <DropdownMenuSeparator />}
|
||||
{qualityOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.level}
|
||||
onClick={() => handleQualityChange(option.level)}
|
||||
className={cn(option.level === selectedQualityLevel && 'font-bold text-primary')}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
|
||||
<Gauge className="h-3.5 w-3.5" />
|
||||
{playbackSpeed === 1 ? '1x' : `${playbackSpeed}x`}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[80px]">
|
||||
{speedOptions.map((speed) => (
|
||||
<DropdownMenuItem
|
||||
key={speed}
|
||||
onClick={() => handleSpeedChange(speed)}
|
||||
className={cn(speed === playbackSpeed && 'font-bold text-primary')}
|
||||
>
|
||||
{speed}x
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreenMode ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
|
||||
>
|
||||
{isFullscreenMode ? <Minimize className="h-4 w-4" /> : <Maximize className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
{isFullscreenMode ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowComments(!showComments)}
|
||||
title={showComments ? 'Hide comments' : 'Show comments'}
|
||||
>
|
||||
{showComments ? <MessageSquareOff className="h-4 w-4" /> : <MessageSquare className="h-4 w-4" />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 lg:hidden"
|
||||
onClick={() => setIsMobileCommentsOpen(true)}
|
||||
title="Show comments"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="relative h-8 bg-muted rounded cursor-pointer select-none"
|
||||
onMouseDown={handleTimelineMouseDown}
|
||||
onMouseMove={handleTimelineMouseMove}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-primary/30 rounded pointer-events-none"
|
||||
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 h-full w-1 bg-primary rounded pointer-events-none"
|
||||
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
|
||||
/>
|
||||
|
||||
{commentMarkers.map((comment) => (
|
||||
<button
|
||||
key={comment.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSeekToTimestamp(comment.timestamp, comment.annotationData);
|
||||
}}
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
|
||||
style={{
|
||||
left: `calc(${duration > 0 ? (comment.timestamp / duration) * 100 : 0}% - 6px)`,
|
||||
backgroundColor: comment.color,
|
||||
}}
|
||||
title={`${formatTime(comment.timestamp)}${comment.preview}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
export interface Version {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
versionLabel: string | null;
|
||||
providerId: string;
|
||||
videoId: string;
|
||||
originalUrl: string;
|
||||
title: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
duration: number | null;
|
||||
isActive: boolean;
|
||||
_count: { comments: number };
|
||||
}
|
||||
|
||||
export interface CommentTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface CommentReply {
|
||||
id: string;
|
||||
content: string | null;
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
annotationData: string | null;
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
guestName: string | null;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
tag: CommentTag | null;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
content: string | null;
|
||||
timestamp: number;
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
annotationData: string | null;
|
||||
isResolved: boolean;
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
guestName: string | null;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
tag: CommentTag | null;
|
||||
replies: CommentReply[];
|
||||
}
|
||||
|
||||
export interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
projectId: string;
|
||||
project: {
|
||||
name: string;
|
||||
ownerId: string;
|
||||
members?: { role: string }[];
|
||||
visibility?: string;
|
||||
};
|
||||
versions: (Version & { comments: Comment[] })[];
|
||||
isAuthenticated: boolean;
|
||||
currentUserId: string | null;
|
||||
currentUserName: string | null;
|
||||
canComment?: boolean;
|
||||
canDownload?: boolean;
|
||||
canManageTags?: boolean;
|
||||
canResolveComments?: boolean;
|
||||
}
|
||||
|
||||
export interface BunnyQualityOption {
|
||||
level: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type BunnyPlaybackState = 'none' | 'processing' | 'error';
|
||||
export type BunnyDownloadPreference = 'original' | 'compressed';
|
||||
export type DownloadTarget = BunnyDownloadPreference | 'direct';
|
||||
|
||||
export interface CommentMarker {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
color: string;
|
||||
annotationData: string | null;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface PlayerAdapter {
|
||||
playVideo: () => void;
|
||||
pauseVideo: () => void;
|
||||
seekTo: (time: number, allowSeekAhead?: boolean) => void;
|
||||
mute: () => void;
|
||||
unMute: () => void;
|
||||
isMuted: () => boolean;
|
||||
getCurrentTime: () => number;
|
||||
getDuration: () => number;
|
||||
getPlayerState: () => number;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
destroy: () => void;
|
||||
off?: (event: string) => void;
|
||||
}
|
||||
|
||||
export interface WatchProgressConfig {
|
||||
videoId: string;
|
||||
activeVersionId: string | null;
|
||||
isAuthenticated: boolean;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export interface WatchProgressState {
|
||||
savedProgress: number | null;
|
||||
showResumePrompt: boolean;
|
||||
}
|
||||
|
||||
export interface CommentActionsConfig {
|
||||
videoId: string;
|
||||
}
|
||||
|
||||
export interface VersionActionsConfig {
|
||||
projectId?: string;
|
||||
videoId: string;
|
||||
}
|
||||
|
||||
export interface VideoPageHeaderActions {
|
||||
onVersionSelect: (versionId: string) => void;
|
||||
onDeleteCurrentVersionClick: () => void;
|
||||
onDownload: (preference?: BunnyDownloadPreference) => void;
|
||||
onOpenCompare: () => void;
|
||||
onCreateVersion: () => void;
|
||||
}
|
||||
|
||||
export interface VideoPageCommentsActions {
|
||||
onExportComments: (format: 'csv' | 'pdf') => void;
|
||||
onResolveComment: (commentId: string, currentlyResolved: boolean) => void;
|
||||
onEditComment: (commentId: string) => void;
|
||||
onDeleteComment: (commentId: string) => void;
|
||||
onReplyComment: (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => void;
|
||||
onSubmitReplyWithMedia: (parentId: string) => void;
|
||||
onStartEditAnnotation: () => void;
|
||||
}
|
||||
|
||||
export interface VideoPageComposerActions {
|
||||
onSubmitCommentWithMedia: () => void;
|
||||
onAddComment: () => void;
|
||||
onPauseVideoForAnnotation: () => void;
|
||||
}
|
||||
|
||||
export interface VideoPageCompareActions {
|
||||
onToggleVersion: (versionId: string) => void;
|
||||
onCompare: () => void;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { AlertCircle, CheckCircle2, FileVideo, Link as LinkIcon, Loader2, Plus, UploadCloud } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { VideoSource } from '@/lib/video-providers';
|
||||
|
||||
interface VersionActionsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
newVersionMode: 'url' | 'file';
|
||||
onNewVersionModeChange: (mode: 'url' | 'file') => void;
|
||||
newVersionUrl: string;
|
||||
onNewVersionUrlChange: (url: string) => void;
|
||||
newVersionUrlError: string;
|
||||
newVersionSource: VideoSource | null;
|
||||
newVersionFile: File | null;
|
||||
onNewVersionFileChange: (file: File | null) => void;
|
||||
newVersionLabel: string;
|
||||
onNewVersionLabelChange: (label: string) => void;
|
||||
newVersionUploadStatus: string;
|
||||
newVersionUploadProgress: number;
|
||||
isCreatingVersion: boolean;
|
||||
versionsCount: number;
|
||||
onCreateVersion: () => void;
|
||||
}
|
||||
|
||||
export const VersionActionsDialog = memo(function VersionActionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
newVersionMode,
|
||||
onNewVersionModeChange,
|
||||
newVersionUrl,
|
||||
onNewVersionUrlChange,
|
||||
newVersionUrlError,
|
||||
newVersionSource,
|
||||
newVersionFile,
|
||||
onNewVersionFileChange,
|
||||
newVersionLabel,
|
||||
onNewVersionLabelChange,
|
||||
newVersionUploadStatus,
|
||||
newVersionUploadProgress,
|
||||
isCreatingVersion,
|
||||
versionsCount,
|
||||
onCreateVersion,
|
||||
}: VersionActionsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Version
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Version</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a new version of this video. The new version will become active.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-2">
|
||||
<Tabs value={newVersionMode} onValueChange={(v) => onNewVersionModeChange(v as 'url' | 'file')} className="mb-2">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="url">Link URL</TabsTrigger>
|
||||
<TabsTrigger value="file">Upload File</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{newVersionMode === 'url' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Video URL</Label>
|
||||
<div className="relative">
|
||||
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
value={newVersionUrl}
|
||||
onChange={(e) => onNewVersionUrlChange(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={isCreatingVersion}
|
||||
/>
|
||||
</div>
|
||||
{newVersionUrlError && (
|
||||
<p className="text-sm text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{newVersionUrlError}
|
||||
</p>
|
||||
)}
|
||||
{newVersionSource && (
|
||||
<p className="text-sm text-green-600 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{newVersionSource.providerId.charAt(0).toUpperCase() +
|
||||
newVersionSource.providerId.slice(1)}{' '}
|
||||
video detected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="versionFile">Video File</Label>
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<label htmlFor="versionFile" className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${newVersionFile ? 'border-primary' : 'border-border'}`}>
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
{newVersionFile ? (
|
||||
<>
|
||||
<FileVideo className="w-8 h-8 mb-2 text-primary" />
|
||||
<p className="mb-1 text-sm text-foreground font-medium truncate max-w-[200px]">{newVersionFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(newVersionFile.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="w-8 h-8 mb-2 text-muted-foreground" />
|
||||
<p className="mb-1 text-sm text-muted-foreground">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input id="versionFile" type="file" accept="video/*" className="hidden" onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type.startsWith('video/')) {
|
||||
onNewVersionFileChange(file);
|
||||
} else {
|
||||
toast.error('Please select a valid video file');
|
||||
}
|
||||
}} disabled={isCreatingVersion} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Version Label (optional)</Label>
|
||||
<Input
|
||||
placeholder="e.g. Final Cut, Review Round 2"
|
||||
value={newVersionLabel}
|
||||
onChange={(e) => onNewVersionLabelChange(e.target.value)}
|
||||
disabled={isCreatingVersion}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newVersionUploadStatus && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{newVersionUploadStatus}</p>
|
||||
{newVersionUploadProgress > 0 && newVersionUploadProgress < 100 && (
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${newVersionUploadProgress}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={onCreateVersion}
|
||||
disabled={(newVersionMode === 'url' && !newVersionSource) || (newVersionMode === 'file' && !newVersionFile) || isCreatingVersion}
|
||||
className="w-full"
|
||||
>
|
||||
{isCreatingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Add Version {versionsCount + 1}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface VersionDeleteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isDeletingVersion: boolean;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const VersionDeleteDialog = memo(function VersionDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
isDeletingVersion,
|
||||
onDelete,
|
||||
}: VersionDeleteDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this version?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this version and all its comments. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingVersion}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onDelete}
|
||||
disabled={isDeletingVersion}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeletingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Delete Version
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface VideoPageErrorProps {
|
||||
containerHeight: string;
|
||||
error: string;
|
||||
mode: 'dashboard' | 'watch';
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export const VideoPageError = memo(function VideoPageError({
|
||||
containerHeight,
|
||||
error,
|
||||
mode,
|
||||
projectId,
|
||||
}: VideoPageErrorProps) {
|
||||
return (
|
||||
<div className={cn(containerHeight, 'flex items-center justify-center bg-background')}>
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-4">{error || 'Video not found'}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={mode === 'dashboard' ? `/projects/${projectId}` : '/'}>
|
||||
{mode === 'dashboard' ? 'Back to Project' : 'Go Home'}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, ChevronDown, GitCompareArrows, MoreVertical, Plus, Share2, Trash2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DownloadControls, DownloadMenuItems } from '@/components/video-page/download-controls';
|
||||
import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog';
|
||||
import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog';
|
||||
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
|
||||
import type { VideoSource } from '@/lib/video-providers';
|
||||
|
||||
interface VideoPageHeaderProps {
|
||||
mode: 'dashboard' | 'watch';
|
||||
backHref: string;
|
||||
title: string;
|
||||
projectName: string;
|
||||
isFullscreenMode: boolean;
|
||||
cursorIdle: boolean;
|
||||
isPlaying: boolean;
|
||||
versions: Version[];
|
||||
activeVersion: Version;
|
||||
activeVersionId: string | null;
|
||||
onVersionSelect: (versionId: string) => void;
|
||||
onDeleteCurrentVersionClick: () => void;
|
||||
showDeleteVersionDialog: boolean;
|
||||
setShowDeleteVersionDialog: (open: boolean) => void;
|
||||
isDeletingVersion: boolean;
|
||||
onDeleteVersion: () => void;
|
||||
videoCanDownload: boolean;
|
||||
isDownloadingVideo: boolean;
|
||||
activeDownloadTarget: DownloadTarget | null;
|
||||
onDownload: (preference?: BunnyDownloadPreference) => void;
|
||||
projectId?: string;
|
||||
videoId: string;
|
||||
showVersionDialog: boolean;
|
||||
setShowVersionDialog: (open: boolean) => void;
|
||||
newVersionMode: 'url' | 'file';
|
||||
setNewVersionMode: (mode: 'url' | 'file') => void;
|
||||
newVersionUrl: string;
|
||||
handleNewVersionUrlChange: (url: string) => void;
|
||||
newVersionUrlError: string;
|
||||
newVersionSource: VideoSource | null;
|
||||
newVersionFile: File | null;
|
||||
setNewVersionFile: (file: File | null) => void;
|
||||
newVersionLabel: string;
|
||||
setNewVersionLabel: (value: string) => void;
|
||||
newVersionUploadStatus: string;
|
||||
newVersionUploadProgress: number;
|
||||
isCreatingVersion: boolean;
|
||||
onCreateVersion: () => void;
|
||||
onOpenCompare: () => void;
|
||||
}
|
||||
|
||||
export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
mode,
|
||||
backHref,
|
||||
title,
|
||||
projectName,
|
||||
isFullscreenMode,
|
||||
cursorIdle,
|
||||
isPlaying,
|
||||
versions,
|
||||
activeVersion,
|
||||
activeVersionId,
|
||||
onVersionSelect,
|
||||
onDeleteCurrentVersionClick,
|
||||
showDeleteVersionDialog,
|
||||
setShowDeleteVersionDialog,
|
||||
isDeletingVersion,
|
||||
onDeleteVersion,
|
||||
videoCanDownload,
|
||||
isDownloadingVideo,
|
||||
activeDownloadTarget,
|
||||
onDownload,
|
||||
projectId,
|
||||
videoId,
|
||||
showVersionDialog,
|
||||
setShowVersionDialog,
|
||||
newVersionMode,
|
||||
setNewVersionMode,
|
||||
newVersionUrl,
|
||||
handleNewVersionUrlChange,
|
||||
newVersionUrlError,
|
||||
newVersionSource,
|
||||
newVersionFile,
|
||||
setNewVersionFile,
|
||||
newVersionLabel,
|
||||
setNewVersionLabel,
|
||||
newVersionUploadStatus,
|
||||
newVersionUploadProgress,
|
||||
isCreatingVersion,
|
||||
onCreateVersion,
|
||||
onOpenCompare,
|
||||
}: VideoPageHeaderProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50',
|
||||
isFullscreenMode ? 'absolute top-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
|
||||
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<div className="hidden sm:block min-w-0">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">• {projectName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
v{activeVersion.versionNumber}
|
||||
</Badge>
|
||||
{activeVersion.versionLabel || `Version ${activeVersion.versionNumber}`}
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{versions.map((version) => (
|
||||
<DropdownMenuItem
|
||||
key={version.id}
|
||||
onClick={() => onVersionSelect(version.id)}
|
||||
>
|
||||
<Badge
|
||||
variant={version.id === activeVersionId ? 'default' : 'secondary'}
|
||||
className="mr-2"
|
||||
>
|
||||
v{version.versionNumber}
|
||||
</Badge>
|
||||
{version.versionLabel || `Version ${version.versionNumber}`}
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{version._count.comments} comments
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{mode === 'dashboard' && versions.length > 1 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onDeleteCurrentVersionClick}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Current Version
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<VersionDeleteDialog
|
||||
open={showDeleteVersionDialog}
|
||||
onOpenChange={setShowDeleteVersionDialog}
|
||||
isDeletingVersion={isDeletingVersion}
|
||||
onDelete={onDeleteVersion}
|
||||
/>
|
||||
|
||||
<DownloadControls
|
||||
activeVersion={activeVersion}
|
||||
videoCanDownload={videoCanDownload}
|
||||
isDownloading={isDownloadingVideo}
|
||||
activeDownloadTarget={activeDownloadTarget}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
|
||||
{mode === 'dashboard' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
|
||||
<Share2 className="h-4 w-4 mr-1" />
|
||||
Share Video
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<VersionActionsDialog
|
||||
open={showVersionDialog}
|
||||
onOpenChange={setShowVersionDialog}
|
||||
newVersionMode={newVersionMode}
|
||||
onNewVersionModeChange={setNewVersionMode}
|
||||
newVersionUrl={newVersionUrl}
|
||||
onNewVersionUrlChange={handleNewVersionUrlChange}
|
||||
newVersionUrlError={newVersionUrlError}
|
||||
newVersionSource={newVersionSource}
|
||||
newVersionFile={newVersionFile}
|
||||
onNewVersionFileChange={setNewVersionFile}
|
||||
newVersionLabel={newVersionLabel}
|
||||
onNewVersionLabelChange={setNewVersionLabel}
|
||||
newVersionUploadStatus={newVersionUploadStatus}
|
||||
newVersionUploadProgress={newVersionUploadProgress}
|
||||
isCreatingVersion={isCreatingVersion}
|
||||
versionsCount={versions.length}
|
||||
onCreateVersion={onCreateVersion}
|
||||
/>
|
||||
|
||||
{versions.length >= 2 && (
|
||||
<Button variant="outline" size="sm" onClick={onOpenCompare}>
|
||||
<GitCompareArrows className="h-4 w-4 mr-1" />
|
||||
Compare
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DownloadMenuItems
|
||||
activeVersion={activeVersion}
|
||||
videoCanDownload={videoCanDownload}
|
||||
isDownloading={isDownloadingVideo}
|
||||
activeDownloadTarget={activeDownloadTarget}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Version
|
||||
</DropdownMenuItem>
|
||||
{versions.length >= 2 && (
|
||||
<DropdownMenuItem onSelect={onOpenCompare}>
|
||||
<GitCompareArrows className="h-4 w-4 mr-2" />
|
||||
Compare
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface VideoPageLoadingProps {
|
||||
containerHeight: string;
|
||||
mode: 'dashboard' | 'watch';
|
||||
isFullscreenMode: boolean;
|
||||
cursorIdle: boolean;
|
||||
isPlaying: boolean;
|
||||
showComments: boolean;
|
||||
}
|
||||
|
||||
export const VideoPageLoading = memo(function VideoPageLoading({
|
||||
containerHeight,
|
||||
mode,
|
||||
isFullscreenMode,
|
||||
cursorIdle,
|
||||
isPlaying,
|
||||
showComments,
|
||||
}: VideoPageLoadingProps) {
|
||||
return (
|
||||
<div className={cn(containerHeight, 'flex flex-col bg-background overflow-hidden')}>
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
<div className={cn('flex-1 flex flex-col overflow-hidden min-h-0', isFullscreenMode && 'relative')}>
|
||||
<div className={cn('shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50', isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none transition-opacity duration-300')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-32 rounded-md" />
|
||||
{mode === 'dashboard' && <Skeleton className="h-8 w-28 rounded-md" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-black min-h-0" />
|
||||
<div className={cn('shrink-0 px-4 py-2 bg-background border-t', isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none transition-opacity duration-300')}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-24 ml-1" />
|
||||
<div className="ml-auto">
|
||||
<Skeleton className="h-8 w-12 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('hidden lg:flex w-80 shrink-0 border-l bg-card flex-col overflow-hidden', isFullscreenMode && !showComments && 'hidden')}>
|
||||
<div className="shrink-0 flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-5 w-6 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-28 rounded-md" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border p-3">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-14 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full mb-1" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="shrink-0 border-t p-4">
|
||||
<Skeleton className="h-20 w-full rounded-md" />
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user