mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
MediaRecorder gives us WebM/Opus, and that is exactly what we stored and served back. Browsers and desktop players read it, but no editing suite does: DaVinci Resolve, Premiere and Final Cut all refuse the container outright, so a voice note downloaded byte-for-byte was useless to the editor it was recorded for. The browser already decodes these formats in order to play them, so the conversion costs nothing but a RIFF header. lib/audio-to-wav.ts decodes through an OfflineAudioContext and writes interleaved 16-bit PCM. This runs at download time rather than at record time, so the stored object stays the small Opus file, uploads keep their 10MB limit, and self-hosted installs gain no server-side ffmpeg dependency. Voice comments had no download control at all, only a play button, so reviewers were saving files straight off the audio element and getting a bare UUID. They now get a download button on both comments and replies, named after the reviewer and the frame they were talking about, gated on the same download permission as the video and asset downloads. Audio assets get a WAV / Original menu. Files already in an editable container (wav, mp3, m4a) are handed over untouched: audio assets are not only recordings, and decoding an uploaded master back out would resample it to 48 kHz and requantise it to 16 bit for no gain. When a browser cannot decode the stored format at all, the original is saved and the user is told.
1262 lines
57 KiB
TypeScript
1262 lines
57 KiB
TypeScript
'use client';
|
|
|
|
import { memo, useState, type ReactNode, type RefObject } from 'react';
|
|
import {
|
|
ArrowUpRight,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
Circle,
|
|
Clock,
|
|
Download,
|
|
FileText,
|
|
FolderOpen,
|
|
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 {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { cn } from '@/lib/utils';
|
|
import { MentionTextarea } from '@/components/video-page/mention-textarea';
|
|
import { CommentRichText } from '@/components/video-page/comment-rich-text';
|
|
import {
|
|
CommentImageGallery,
|
|
ImageAttachmentStrip,
|
|
} from '@/components/video-page/image-attachments';
|
|
import type { ImageAttachTarget } from '@/components/video-page/hooks/use-comment-actions';
|
|
import { MAX_COMMENT_IMAGES } from '@/lib/comment-images';
|
|
import type {
|
|
Comment,
|
|
CommentReply,
|
|
CommentTag,
|
|
Version,
|
|
VideoAsset,
|
|
} 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,
|
|
options?: { pauseAfterSeek?: boolean; timestampEnd?: number | null }
|
|
) => void;
|
|
currentUserId: string | null;
|
|
projectOwnerId: string;
|
|
editingCommentId: string | null;
|
|
startEditingComment: (comment: Comment) => void;
|
|
startEditingReply: (reply: CommentReply) => void;
|
|
cancelEditingComment: () => void;
|
|
editText: string;
|
|
setEditText: (value: string) => void;
|
|
editTagId: string | null | undefined;
|
|
setEditTagId: (value: string | null | undefined) => void;
|
|
editImageUrls: string[];
|
|
editImageFiles: File[];
|
|
editImageInputRef: RefObject<HTMLInputElement | null>;
|
|
removeEditImageUrl: (url: string) => void;
|
|
onStartEditAnnotation: () => void;
|
|
isSubmittingEdit: boolean;
|
|
availableTags: CommentTag[];
|
|
handleEditComment: (commentId: string) => void;
|
|
handleDeleteComment: (commentId: string) => void;
|
|
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
|
|
downloadVoice: (commentId: string, voiceUrl: string, baseName: string) => void;
|
|
downloadingVoiceIds: ReadonlySet<string>;
|
|
/** Same gate as the video and asset downloads: a project or share link with
|
|
* downloads disabled must not offer to save voice notes either. */
|
|
canDownloadVoiceNotes: boolean;
|
|
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;
|
|
replyRangeStart: number | null;
|
|
replyRangeEnd: number | null;
|
|
toggleReplyRangeSelection: () => void;
|
|
clearReplyRangeSelection: () => void;
|
|
handleReplyComment: (
|
|
parentId: string,
|
|
voiceData?: { url: string; duration: number },
|
|
imageUrls?: string[]
|
|
) => void;
|
|
startReplyRecording: () => void;
|
|
isReplyRecording: boolean;
|
|
replyRecordingTime: number;
|
|
stopReplyRecording: () => void;
|
|
cancelReplyRecording: () => void;
|
|
replyAudioBlob: Blob | null;
|
|
replyImageFiles: File[];
|
|
replyImageInputRef: RefObject<HTMLInputElement | null>;
|
|
removeImageFile: (index: number, target: ImageAttachTarget) => void;
|
|
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, target?: ImageAttachTarget) => void;
|
|
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, target?: ImageAttachTarget) => void;
|
|
handleDrop: (e: React.DragEvent<HTMLDivElement>, target?: ImageAttachTarget) => void;
|
|
submitReplyWithMedia: (parentId: string) => void;
|
|
isSubmittingReply: boolean;
|
|
isUploadingReplyAudio: boolean;
|
|
isUploadingReplyImage: boolean;
|
|
composer: ReactNode;
|
|
assets: VideoAsset[];
|
|
onAssetMentionClick: (assetId: string) => void;
|
|
activePane: 'comments' | 'assets';
|
|
setActivePane: (pane: 'comments' | 'assets') => void;
|
|
assetsPane: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Names the download after the reviewer and the frame they were talking about,
|
|
* so a folder of voice notes still makes sense next to the cut.
|
|
*/
|
|
function voiceNoteFileName(
|
|
entry: { timestamp: number; author: { name: string | null } | null; guestName: string | null },
|
|
formatTime: (seconds: number) => string
|
|
): string {
|
|
const who = entry.author?.name || entry.guestName || 'guest';
|
|
return `voice-${who}-${formatTime(entry.timestamp).replace(/:/g, '-')}`;
|
|
}
|
|
|
|
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,
|
|
startEditingComment,
|
|
startEditingReply,
|
|
cancelEditingComment,
|
|
editText,
|
|
setEditText,
|
|
editTagId,
|
|
setEditTagId,
|
|
editImageUrls,
|
|
editImageFiles,
|
|
editImageInputRef,
|
|
removeEditImageUrl,
|
|
onStartEditAnnotation,
|
|
isSubmittingEdit,
|
|
availableTags,
|
|
handleEditComment,
|
|
handleDeleteComment,
|
|
playVoice,
|
|
downloadVoice,
|
|
downloadingVoiceIds,
|
|
canDownloadVoiceNotes,
|
|
playingVoiceId,
|
|
voiceProgress,
|
|
voiceCurrentTime,
|
|
voicePlaybackRate,
|
|
toggleVoiceSpeed,
|
|
formatTime,
|
|
setPreviewImage,
|
|
replyingTo,
|
|
setReplyingTo,
|
|
replyText,
|
|
setReplyText,
|
|
replyRangeStart,
|
|
replyRangeEnd,
|
|
toggleReplyRangeSelection,
|
|
clearReplyRangeSelection,
|
|
handleReplyComment,
|
|
startReplyRecording,
|
|
isReplyRecording,
|
|
replyRecordingTime,
|
|
stopReplyRecording,
|
|
cancelReplyRecording,
|
|
replyAudioBlob,
|
|
replyImageFiles,
|
|
replyImageInputRef,
|
|
removeImageFile,
|
|
handleImageSelect,
|
|
handlePaste,
|
|
handleDrop,
|
|
submitReplyWithMedia,
|
|
isSubmittingReply,
|
|
isUploadingReplyAudio,
|
|
isUploadingReplyImage,
|
|
composer,
|
|
assets,
|
|
onAssetMentionClick,
|
|
activePane,
|
|
setActivePane,
|
|
assetsPane,
|
|
}: CommentsPaneProps) {
|
|
const [isPaneDraggingOver, setIsPaneDraggingOver] = useState(false);
|
|
const formatCommentRange = (timestamp: number, timestampEnd: number | null) => {
|
|
if (timestampEnd === null) return formatTime(timestamp);
|
|
return `${formatTime(timestamp)} - ${formatTime(timestampEnd)}`;
|
|
};
|
|
const replyRangeButtonLabel =
|
|
replyRangeStart === null || replyRangeEnd !== null ? 'Set In' : 'Set Out';
|
|
const replyRangeLabel =
|
|
replyRangeStart !== null
|
|
? replyRangeEnd !== null
|
|
? `${formatTime(replyRangeStart)} - ${formatTime(replyRangeEnd)}`
|
|
: `In ${formatTime(replyRangeStart)}`
|
|
: null;
|
|
|
|
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 relative',
|
|
'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' : ''
|
|
)}
|
|
onDragOver={(e) => {
|
|
if (activePane !== 'comments') return;
|
|
e.preventDefault();
|
|
setIsPaneDraggingOver(true);
|
|
}}
|
|
onDragEnter={(e) => {
|
|
if (activePane !== 'comments') return;
|
|
e.preventDefault();
|
|
setIsPaneDraggingOver(true);
|
|
}}
|
|
onDragLeave={(e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsPaneDraggingOver(false);
|
|
}}
|
|
onDrop={(e) => {
|
|
setIsPaneDraggingOver(false);
|
|
if (activePane !== 'comments') return;
|
|
handleDrop(
|
|
e,
|
|
editingCommentId !== null ? 'edit' : replyingTo !== null ? 'reply' : 'comment'
|
|
);
|
|
}}
|
|
>
|
|
{isPaneDraggingOver && (
|
|
<div className="absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-primary bg-primary/10 pointer-events-none">
|
|
<p className="text-sm font-medium text-primary">Drop images to attach</p>
|
|
</div>
|
|
)}
|
|
<div className="shrink-0 p-4 border-b lg:cursor-default space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-1 min-w-0 overflow-x-auto">
|
|
<Button
|
|
variant={activePane === 'comments' ? 'default' : 'ghost'}
|
|
size="sm"
|
|
className="h-8 shrink-0"
|
|
onClick={() => setActivePane('comments')}
|
|
>
|
|
<MessageSquare className="h-4 w-4 mr-1" />
|
|
Comments
|
|
<Badge variant="secondary" className="ml-2">
|
|
{comments.length}
|
|
</Badge>
|
|
</Button>
|
|
<Button
|
|
variant={activePane === 'assets' ? 'default' : 'ghost'}
|
|
size="sm"
|
|
className="h-8 shrink-0"
|
|
onClick={() => setActivePane('assets')}
|
|
>
|
|
<FolderOpen className="h-4 w-4 mr-1" />
|
|
Assets
|
|
<Badge variant="secondary" className="ml-2">
|
|
{assets.length}
|
|
</Badge>
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 lg:hidden shrink-0"
|
|
onClick={() => setIsMobileCommentsOpen(false)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{activePane === 'comments' && (
|
|
<div className="flex w-full items-center justify-end gap-2 flex-wrap">
|
|
<Button
|
|
variant={showResolved ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleToggleShowResolved();
|
|
}}
|
|
>
|
|
Resolved
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 px-2"
|
|
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
|
aria-label="Download comments"
|
|
title="Download comments"
|
|
>
|
|
{isExportingCsv || isExportingPdf ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Download className="h-4 w-4" />
|
|
)}
|
|
<ChevronDown className="h-4 w-4 ml-0.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuItem
|
|
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleExportComments('csv');
|
|
}}
|
|
title={
|
|
isGuest
|
|
? 'CSV export requires an authenticated account'
|
|
: 'Download comments as CSV'
|
|
}
|
|
>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
Download CSV
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleExportComments('pdf');
|
|
}}
|
|
title="Download comments as PDF"
|
|
>
|
|
<FileText className="h-4 w-4 mr-2" />
|
|
Download PDF
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div
|
|
className={cn(activePane === 'assets' ? 'block p-4' : 'hidden')}
|
|
aria-hidden={activePane !== 'assets'}
|
|
>
|
|
{assetsPane}
|
|
</div>
|
|
|
|
<div
|
|
className={cn(activePane === 'comments' ? 'block p-4 space-y-3' : 'hidden')}
|
|
aria-hidden={activePane !== 'comments'}
|
|
>
|
|
{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, {
|
|
pauseAfterSeek: true,
|
|
timestampEnd: comment.timestampEnd,
|
|
})
|
|
}
|
|
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" />
|
|
{formatCommentRange(comment.timestamp, comment.timestampEnd)}
|
|
<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={() => {
|
|
clearReplyRangeSelection();
|
|
setReplyingTo(comment.id);
|
|
setReplyText('');
|
|
}}
|
|
>
|
|
<Reply className="h-4 w-4 mr-2" />
|
|
Reply
|
|
</DropdownMenuItem>
|
|
{canEditComment && (
|
|
<DropdownMenuItem onClick={() => startEditingComment(comment)}>
|
|
<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">
|
|
<MentionTextarea
|
|
value={editText}
|
|
onChange={setEditText}
|
|
assets={assets}
|
|
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') {
|
|
cancelEditingComment();
|
|
}
|
|
}}
|
|
onPaste={(e) => handlePaste(e, 'edit')}
|
|
/>
|
|
<ImageAttachmentStrip
|
|
existingUrls={editImageUrls}
|
|
onRemoveExisting={removeEditImageUrl}
|
|
files={editImageFiles}
|
|
onRemoveFile={(index) => removeImageFile(index, 'edit')}
|
|
compact
|
|
/>
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleEditComment(comment.id)}
|
|
disabled={
|
|
(!editText.trim() &&
|
|
editImageUrls.length === 0 &&
|
|
editImageFiles.length === 0) ||
|
|
isSubmittingEdit
|
|
}
|
|
className="h-7 text-xs"
|
|
>
|
|
{isSubmittingEdit ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
'Save'
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={cancelEditingComment}
|
|
className="h-7 text-xs"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="outline"
|
|
className="h-7 w-7"
|
|
onClick={() => editImageInputRef.current?.click()}
|
|
disabled={
|
|
editImageUrls.length + editImageFiles.length >= MAX_COMMENT_IMAGES
|
|
}
|
|
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
|
|
>
|
|
<ImageIcon className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
ref={editImageInputRef}
|
|
onChange={(e) => handleImageSelect(e, 'edit')}
|
|
/>
|
|
<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 whitespace-pre-wrap break-words">
|
|
<CommentRichText
|
|
text={comment.content}
|
|
onAssetMentionClick={onAssetMentionClick}
|
|
assets={assets}
|
|
/>
|
|
</p>
|
|
)}
|
|
<CommentImageGallery
|
|
images={comment.images}
|
|
onOpen={setPreviewImage}
|
|
className="mb-2"
|
|
/>
|
|
</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>
|
|
)}
|
|
{canDownloadVoiceNotes && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8 shrink-0"
|
|
title="Download as WAV"
|
|
aria-label="Download voice note as WAV"
|
|
disabled={downloadingVoiceIds.has(comment.id)}
|
|
onClick={() =>
|
|
downloadVoice(
|
|
comment.id,
|
|
comment.voiceUrl!,
|
|
voiceNoteFileName(comment, formatTime)
|
|
)
|
|
}
|
|
>
|
|
{downloadingVoiceIds.has(comment.id) ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Download className="h-4 w-4" />
|
|
)}
|
|
</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>
|
|
<button
|
|
onClick={() =>
|
|
handleSeekToTimestamp(reply.timestamp, reply.annotationData, {
|
|
pauseAfterSeek: true,
|
|
timestampEnd: reply.timestampEnd,
|
|
})
|
|
}
|
|
className="flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary transition-colors hover:bg-primary/20"
|
|
title="Jump to this reply"
|
|
>
|
|
<Clock className="h-2.5 w-2.5" />
|
|
{formatCommentRange(reply.timestamp, reply.timestampEnd)}
|
|
</button>
|
|
<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={() => startEditingReply(reply)}>
|
|
<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">
|
|
<MentionTextarea
|
|
value={editText}
|
|
onChange={setEditText}
|
|
assets={assets}
|
|
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') {
|
|
cancelEditingComment();
|
|
}
|
|
}}
|
|
onPaste={(e) => handlePaste(e, 'edit')}
|
|
/>
|
|
<ImageAttachmentStrip
|
|
existingUrls={editImageUrls}
|
|
onRemoveExisting={removeEditImageUrl}
|
|
files={editImageFiles}
|
|
onRemoveFile={(index) => removeImageFile(index, 'edit')}
|
|
compact
|
|
/>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleEditComment(reply.id)}
|
|
disabled={
|
|
(!editText.trim() &&
|
|
editImageUrls.length === 0 &&
|
|
editImageFiles.length === 0) ||
|
|
isSubmittingEdit
|
|
}
|
|
className="h-7 text-xs"
|
|
>
|
|
{isSubmittingEdit ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
'Save'
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={cancelEditingComment}
|
|
className="h-7 text-xs"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="outline"
|
|
className="h-7 w-7"
|
|
onClick={() => editImageInputRef.current?.click()}
|
|
disabled={
|
|
editImageUrls.length + editImageFiles.length >=
|
|
MAX_COMMENT_IMAGES
|
|
}
|
|
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
|
|
>
|
|
<ImageIcon className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
ref={editImageInputRef}
|
|
onChange={(e) => handleImageSelect(e, 'edit')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mb-1">
|
|
{reply.content && (
|
|
<p className="text-sm whitespace-pre-wrap break-words">
|
|
<CommentRichText
|
|
text={reply.content}
|
|
onAssetMentionClick={onAssetMentionClick}
|
|
assets={assets}
|
|
/>
|
|
</p>
|
|
)}
|
|
<CommentImageGallery
|
|
images={reply.images}
|
|
onOpen={setPreviewImage}
|
|
compact
|
|
className="mt-2"
|
|
/>
|
|
</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>
|
|
)}
|
|
{canDownloadVoiceNotes && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6 shrink-0"
|
|
title="Download as WAV"
|
|
aria-label="Download voice note as WAV"
|
|
disabled={downloadingVoiceIds.has(reply.id)}
|
|
onClick={() =>
|
|
downloadVoice(
|
|
reply.id,
|
|
reply.voiceUrl!,
|
|
voiceNoteFileName(reply, formatTime)
|
|
)
|
|
}
|
|
>
|
|
{downloadingVoiceIds.has(reply.id) ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Download className="h-3 w-3" />
|
|
)}
|
|
</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>
|
|
|
|
<ImageAttachmentStrip
|
|
files={replyImageFiles}
|
|
onRemoveFile={(index) => removeImageFile(index, 'reply')}
|
|
compact
|
|
/>
|
|
|
|
<MentionTextarea
|
|
value={replyText}
|
|
onChange={setReplyText}
|
|
assets={assets}
|
|
placeholder="Add a note (optional)..."
|
|
rows={1}
|
|
className="resize-none text-sm"
|
|
/>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Button
|
|
size="sm"
|
|
variant={replyRangeStart !== null ? 'default' : 'outline'}
|
|
className="h-7 text-xs"
|
|
onClick={toggleReplyRangeSelection}
|
|
>
|
|
{replyRangeButtonLabel}
|
|
</Button>
|
|
{replyRangeLabel && (
|
|
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
|
|
{replyRangeLabel}
|
|
</span>
|
|
)}
|
|
{replyRangeStart !== null && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 text-xs"
|
|
onClick={clearReplyRangeSelection}
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<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>
|
|
) : (
|
|
<>
|
|
<ImageAttachmentStrip
|
|
files={replyImageFiles}
|
|
onRemoveFile={(index) => removeImageFile(index, 'reply')}
|
|
compact
|
|
/>
|
|
<div className="flex gap-1">
|
|
<MentionTextarea
|
|
value={replyText}
|
|
onChange={setReplyText}
|
|
assets={assets}
|
|
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') {
|
|
clearReplyRangeSelection();
|
|
setReplyingTo(null);
|
|
setReplyText('');
|
|
}
|
|
}}
|
|
onPaste={(e) => handlePaste(e, 'reply')}
|
|
/>
|
|
<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()}
|
|
disabled={replyImageFiles.length >= MAX_COMMENT_IMAGES}
|
|
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
|
|
className="h-8 w-8 shrink-0 self-end"
|
|
>
|
|
<ImageIcon className="h-3 w-3" />
|
|
</Button>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
ref={replyImageInputRef}
|
|
onChange={(e) => handleImageSelect(e, 'reply')}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
|
<Button
|
|
size="sm"
|
|
variant={replyRangeStart !== null ? 'default' : 'outline'}
|
|
className="h-7 text-xs"
|
|
onClick={toggleReplyRangeSelection}
|
|
>
|
|
{replyRangeButtonLabel}
|
|
</Button>
|
|
{replyRangeLabel && (
|
|
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
|
|
{replyRangeLabel}
|
|
</span>
|
|
)}
|
|
{replyRangeStart !== null && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 text-xs"
|
|
onClick={clearReplyRangeSelection}
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-1 mt-1">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleReplyComment(comment.id)}
|
|
disabled={
|
|
(!replyText.trim() && replyImageFiles.length === 0) ||
|
|
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={() => {
|
|
clearReplyRangeSelection();
|
|
setReplyingTo(null);
|
|
setReplyText('');
|
|
}}
|
|
className="h-7 text-xs"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isReplying && !isEditing && (
|
|
<button
|
|
onClick={() => {
|
|
clearReplyRangeSelection();
|
|
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>
|
|
</div>
|
|
|
|
{activePane === 'comments' ? composer : null}
|
|
</div>
|
|
</>
|
|
);
|
|
});
|