mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
A comment held one image, and the paste handler took the first item off the clipboard and dropped the rest. Reviewing a cut usually means several screenshots about the same moment, which meant one comment per screenshot or one screenshot and a paragraph describing the others. Editing a comment could not attach anything at all: the edit box had no paste handler, no file picker and no way to remove what was already there. A comment now carries up to five images, in the composer, in a reply and in the editor. One paste stages every image on the clipboard, the file picker takes a multiple selection, and a drop lands on whichever editor is open. Over the cap the extras are refused out loud rather than dropped quietly. A single image still fills the width; several tile into a grid, and either opens full screen on click. The images move into their own table. `comments.imageUrl` stays and follows the first of them, so a reader that has not been updated keeps working, and the migration copies the existing attachments across so the new table is complete from the first read. Every path that resolves a URL back to a comment now asks the new table: R2 cleanup, the orphan sweep, the storage accounting and the reference checks that decide whether an object can be deleted. Left on the old column they would have treated images two through five as unreferenced and swept them. Detaching an image while editing only breaks the link. The file stays in R2 and in the assets pane, which is where it is deleted from and where its bytes are already billed.
1196 lines
54 KiB
TypeScript
1196 lines
54 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;
|
|
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;
|
|
}
|
|
|
|
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,
|
|
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>
|
|
)}
|
|
</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>
|
|
)}
|
|
</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>
|
|
</>
|
|
);
|
|
});
|