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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user