feat: implement R2 audio file management and rate limiting enhancements

- Add R2 client setup and audio upload functionality in lib/r2.ts.
- Create audio file cleanup functions in lib/r2-cleanup.ts to delete voice files associated with videos, projects, and workspaces.
- Enhance rate limiting in lib/rate-limit.ts with new action-specific limits and improved IP validation.
- Introduce a unified rate limit check function that returns a 429 response when limits are exceeded.
- Update package.json to include the AWS SDK for S3.
This commit is contained in:
Yusuf İpek
2026-02-07 12:27:40 +03:00
parent f240689e27
commit 296c5257a7
23 changed files with 1913 additions and 181 deletions
@@ -82,6 +82,8 @@ interface Comment {
replies: {
id: string;
content: string | null;
voiceUrl: string | null;
voiceDuration: number | null;
createdAt: string;
author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null;
@@ -133,6 +135,19 @@ export default function VideoPage() {
const [commentText, setCommentText] = useState('');
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const [voiceProgress, setVoiceProgress] = useState(0);
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const voiceRafRef = useRef<number | null>(null);
const voiceKnownDurationRef = useRef<number>(0);
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
const [showResolved, setShowResolved] = useState(false);
@@ -140,6 +155,13 @@ export default function VideoPage() {
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [isReplyRecording, setIsReplyRecording] = useState(false);
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
const replyAudioChunksRef = useRef<Blob[]>([]);
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editText, setEditText] = useState('');
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
@@ -336,8 +358,9 @@ export default function VideoPage() {
}
}, [isDragging, currentTime, handleSeekToTimestamp]);
const handleAddComment = useCallback(async () => {
if (!commentText.trim() || !activeVersion) return;
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
if (!voiceData && !commentText.trim()) return;
if (!activeVersion) return;
setIsSubmittingComment(true);
try {
@@ -345,8 +368,9 @@ export default function VideoPage() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: commentText,
content: voiceData ? commentText.trim() || null : commentText,
timestamp: selectedTimestamp ?? currentTime,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
}),
});
@@ -373,6 +397,192 @@ export default function VideoPage() {
}
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId]);
// Voice recording handlers
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
audioChunksRef.current = [];
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsRecording(true);
setRecordingTime(0);
recordingTimerRef.current = setInterval(() => {
setRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start recording:', err);
}
}, []);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
}, []);
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
setAudioBlob(null);
setRecordingTime(0);
}, []);
const submitVoiceComment = useCallback(async () => {
if (!audioBlob || !activeVersion) return;
setIsUploadingAudio(true);
try {
// Upload audio to R2
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', {
method: 'POST',
body: formData,
});
if (!uploadRes.ok) {
throw new Error('Failed to upload audio');
}
const { url } = await uploadRes.json();
// Submit comment with voice URL
await handleAddComment({ url, duration: recordingTime });
setAudioBlob(null);
setRecordingTime(0);
} catch (err) {
console.error('Failed to submit voice comment:', err);
} finally {
setIsUploadingAudio(false);
}
}, [audioBlob, activeVersion, recordingTime, handleAddComment]);
// Voice playback
const stopVoiceTracking = useCallback(() => {
if (voiceRafRef.current) {
cancelAnimationFrame(voiceRafRef.current);
voiceRafRef.current = null;
}
}, []);
const startVoiceTracking = useCallback(() => {
stopVoiceTracking();
const tick = () => {
const audio = audioPlayerRef.current;
if (audio) {
const dur = isFinite(audio.duration) && audio.duration > 0
? audio.duration
: voiceKnownDurationRef.current;
if (dur > 0) {
setVoiceProgress((audio.currentTime / dur) * 100);
setVoiceCurrentTime(audio.currentTime);
}
}
voiceRafRef.current = requestAnimationFrame(tick);
};
voiceRafRef.current = requestAnimationFrame(tick);
}, [stopVoiceTracking]);
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
if (playingVoiceId === commentId) {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
return;
}
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
}
stopVoiceTracking();
voiceKnownDurationRef.current = knownDuration || 0;
const audio = new Audio(voiceUrl);
audio.playbackRate = voicePlaybackRate;
audioPlayerRef.current = audio;
setPlayingVoiceId(commentId);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audio.onplay = () => {
startVoiceTracking();
};
audio.onended = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onerror = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.play();
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
const toggleVoiceSpeed = useCallback(() => {
setVoicePlaybackRate((prev) => {
const next = prev === 1 ? 2 : 1;
if (audioPlayerRef.current) {
audioPlayerRef.current.playbackRate = next;
}
return next;
});
}, []);
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
}
};
}, []);
const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => {
try {
@@ -408,17 +618,19 @@ export default function VideoPage() {
);
// Reply to a comment
const handleReplyComment = useCallback(async (parentId: string) => {
if (!replyText.trim() || !activeVersion) return;
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
if (!voiceData && !replyText.trim()) return;
if (!activeVersion) return;
setIsSubmittingReply(true);
try {
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: replyText,
content: voiceData ? replyText.trim() || null : replyText,
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
}),
});
if (res.ok) {
@@ -443,6 +655,8 @@ export default function VideoPage() {
});
setReplyText('');
setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
}
} catch (err) {
console.error('Failed to reply:', err);
@@ -451,6 +665,73 @@ export default function VideoPage() {
}
}, [replyText, activeVersion, activeVersionId, comments, currentTime]);
// Voice recording for replies
const startReplyRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
replyAudioChunksRef.current = [];
replyMediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
setReplyAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (replyRecordingTimerRef.current) {
clearInterval(replyRecordingTimerRef.current);
replyRecordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsReplyRecording(true);
setReplyRecordingTime(0);
replyRecordingTimerRef.current = setInterval(() => {
setReplyRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start reply recording:', err);
}
}, []);
const stopReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
}, []);
const cancelReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
}, []);
const submitVoiceReply = useCallback(async (parentId: string) => {
if (!replyAudioBlob || !activeVersion) return;
setIsUploadingReplyAudio(true);
try {
const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio');
const { url } = await uploadRes.json();
await handleReplyComment(parentId, { url, duration: replyRecordingTime });
} catch (err) {
console.error('Failed to submit voice reply:', err);
} finally {
setIsUploadingReplyAudio(false);
}
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]);
// Edit a comment
const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim()) return;
@@ -1055,15 +1336,37 @@ export default function VideoPage() {
{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">
<Play className="h-4 w-4" />
<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-1 bg-primary/30 rounded">
<div className="w-0 h-full bg-primary rounded" />
<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">
{formatTime(comment.voiceDuration || 0)}
<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>
)}
@@ -1158,7 +1461,42 @@ export default function VideoPage() {
</div>
</div>
) : (
<p className="text-sm">{reply.content}</p>
reply.content && <p className="text-sm">{reply.content}</p>
)}
{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>
);
@@ -1169,41 +1507,126 @@ export default function VideoPage() {
{/* Inline reply form */}
{isReplying && (
<div className="mt-3 pl-3 border-l-2">
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Write a reply..."
rows={2}
className="resize-none text-sm mb-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleReplyComment(comment.id);
}
if (e.key === 'Escape') {
setReplyingTo(null);
setReplyText('');
}
}}
/>
<div className="flex gap-1">
<Button
size="sm"
onClick={() => handleReplyComment(comment.id)}
disabled={!replyText.trim() || isSubmittingReply}
className="h-7 text-xs"
>
{isSubmittingReply ? <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>
{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>
<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">
<Button
size="sm"
onClick={() => submitVoiceReply(comment.id)}
disabled={isUploadingReplyAudio}
className="h-7 text-xs"
>
{isUploadingReplyAudio ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Voice Reply'}
</Button>
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</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('');
}
}}
/>
<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>
</div>
<div className="flex gap-1 mt-1">
<Button
size="sm"
onClick={() => handleReplyComment(comment.id)}
disabled={!replyText.trim() || isSubmittingReply}
className="h-7 text-xs"
>
{isSubmittingReply ? <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>
)}
@@ -1227,52 +1650,152 @@ export default function VideoPage() {
<div className="shrink-0 p-4 border-t bg-background">
<div className="flex items-center gap-2 mb-2">
<Button
variant="outline"
variant={selectedTimestamp !== null ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedTimestamp(currentTime)}
className={cn(selectedTimestamp !== null && 'border-primary')}
onClick={() => {
if (selectedTimestamp !== null) {
setSelectedTimestamp(null);
} else {
setSelectedTimestamp(currentTime);
}
}}
>
<Clock className="h-4 w-4 mr-1" />
{selectedTimestamp !== null ? formatTime(selectedTimestamp) : formatTime(currentTime)}
{selectedTimestamp !== null && <X className="h-3 w-3 ml-1" />}
</Button>
<span className="text-xs text-muted-foreground">Pin to this time</span>
<span className="text-xs text-muted-foreground">
{selectedTimestamp !== null ? 'Pinned — click to unpin' : 'Pin to this time'}
</span>
</div>
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={handleAddComment}
disabled={!commentText.trim() || isSubmittingComment}
>
{isSubmittingComment ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
{/* Recording state UI */}
{isRecording ? (
<div className="flex items-center gap-3 p-3 bg-destructive/10 border border-destructive/30 rounded-lg">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
<span className="text-sm font-medium text-destructive">
Recording {formatTime(recordingTime)}
</span>
<div className="flex-1" />
<Button size="sm" variant="destructive" onClick={stopRecording}>
Stop
</Button>
<Button
size="icon"
variant={isRecording ? 'destructive' : 'outline'}
onClick={() => setIsRecording(!isRecording)}
>
<Mic className={cn('h-4 w-4', isRecording && 'animate-pulse')} />
<Button size="sm" variant="ghost" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
) : audioBlob ? (
<div className="space-y-2">
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => {
const url = URL.createObjectURL(audioBlob);
playVoice('preview', url, recordingTime);
}}
>
{playingVoiceId === 'preview' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'preview' ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
: formatTime(recordingTime)}
</span>
{playingVoiceId === 'preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={cancelRecording}
>
<X className="h-4 w-4" />
</Button>
</div>
<Textarea
placeholder="Add a note to your voice comment (optional)..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={1}
className="resize-none text-sm"
/>
<Button
size="sm"
onClick={submitVoiceComment}
disabled={isUploadingAudio}
className="w-full"
>
{isUploadingAudio ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Uploading...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Voice Comment
</>
)}
</Button>
</div>
) : (
<>
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={() => handleAddComment()}
disabled={!commentText.trim() || isSubmittingComment}
>
{isSubmittingComment ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
onClick={startRecording}
title="Record voice comment"
>
<Mic className="h-4 w-4" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
</>
)}
</div>
</div>
</div>
+10 -1
View File
@@ -1,3 +1,12 @@
import { handlers } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
export const { GET, POST } = handlers;
export const { GET } = handlers;
// Wrap NextAuth POST with login rate limiting
export async function POST(request: Request) {
const limited = await rateLimit(request, 'login');
if (limited) return limited;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return handlers.POST(request as any);
}
+37
View File
@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ commentId: string }> };
@@ -65,6 +68,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// PATCH /api/comments/[commentId]
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { commentId } = await params;
@@ -152,6 +158,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/comments/[commentId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { commentId } = await params;
@@ -162,6 +171,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const comment = await db.comment.findUnique({
where: { id: commentId },
include: {
replies: { select: { voiceUrl: true } },
version: {
include: {
video: { include: { project: true } },
@@ -184,8 +194,35 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
);
}
// Collect all voice URLs to delete from R2 (comment + its replies)
const voiceUrls: string[] = [];
if (comment.voiceUrl) voiceUrls.push(comment.voiceUrl);
for (const reply of comment.replies) {
if (reply.voiceUrl) voiceUrls.push(reply.voiceUrl);
}
await db.comment.delete({ where: { id: commentId } });
// Clean up audio files from R2 (best-effort, don't block on failure)
const AUDIO_PREFIX = '/api/upload/audio/';
for (const url of voiceUrls) {
try {
// Extract filename using string parsing (safe against ReDoS)
const idx = url.indexOf(AUDIO_PREFIX);
const filename = idx !== -1 ? url.slice(idx + AUDIO_PREFIX.length) : null;
if (filename) {
await r2Client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: `voice/${filename}`,
})
);
}
} catch (err) {
console.error('Failed to delete audio from R2:', err);
}
}
return NextResponse.json({ success: true, message: 'Comment deleted' });
} catch (error) {
console.error('Error deleting comment:', error);
@@ -2,12 +2,16 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string; memberId: string }> };
// PATCH /api/projects/[projectId]/members/[memberId] - Update member role
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth();
const { projectId, memberId } = await params;
@@ -63,6 +67,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/projects/[projectId]/members/[memberId] - Remove member
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth();
const { projectId, memberId } = await params;
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -59,6 +60,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/projects/[projectId]/members - Invite a member
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'invite-member');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
+11
View File
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole, ProjectVisibility } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupProjectVoiceFiles } from '@/lib/r2-cleanup';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -117,6 +119,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// PATCH /api/projects/[projectId] - Update a project
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
@@ -159,6 +164,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/projects/[projectId] - Delete a project
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
@@ -179,6 +187,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
);
}
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupProjectVoiceFiles(projectId);
await db.project.delete({ where: { id: projectId } });
return NextResponse.json({ success: true, message: 'Project deleted' });
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -65,6 +67,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// PATCH /api/projects/[projectId]/videos/[videoId]
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, videoId } = await params;
@@ -122,6 +127,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/projects/[projectId]/videos/[videoId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, videoId } = await params;
@@ -152,6 +160,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
);
}
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupVideoVoiceFiles(videoId);
await db.video.delete({ where: { id: videoId } });
return NextResponse.json({ success: true, message: 'Video deleted' });
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client';
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -54,6 +55,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/projects/[projectId]/videos/[videoId]/versions - Add a new version
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'create-version');
if (limited) return limited;
const session = await auth();
const { projectId, videoId } = await params;
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client';
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -57,6 +58,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/projects/[projectId]/videos - Add a new video to the project
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'create-video');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
+4
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectVisibility } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
// GET /api/projects - List all projects for the authenticated user
export async function GET(request: NextRequest) {
@@ -75,6 +76,9 @@ export async function GET(request: NextRequest) {
// POST /api/projects - Create a new project
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'create-project');
if (limited) return limited;
const session = await auth();
if (!session?.user?.id) {
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { GetObjectCommand } from '@aws-sdk/client-s3';
// Only allow UUID filenames with safe extensions
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export async function GET(
_request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename to prevent path traversal
if (!SAFE_FILENAME.test(filename)) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
const key = `voice/${filename}`;
const response = await r2Client.send(
new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
if (!response.Body) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const contentType = response.ContentType || 'audio/webm';
const contentLength = response.ContentLength;
// Stream the response body directly instead of buffering in memory
const stream = response.Body.transformToWebStream();
return new NextResponse(stream, {
status: 200,
headers: {
'Content-Type': contentType,
...(contentLength ? { 'Content-Length': String(contentLength) } : {}),
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error: unknown) {
const errorName = error instanceof Error ? error.name : '';
if (errorName === 'NoSuchKey') {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
console.error('Error serving audio:', error);
return NextResponse.json(
{ error: 'Failed to retrieve audio' },
{ status: 500 }
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { rateLimit } from '@/lib/rate-limit';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav'];
export async function POST(request: Request) {
try {
// Rate limit
const limited = await rateLimit(request, 'voice-upload');
if (limited) return limited;
// Require authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('audio') as File | null;
if (!file) {
return NextResponse.json({ error: 'No audio file provided' }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: 'File too large. Maximum size is 10MB.' },
{ status: 400 }
);
}
// Check content type
const contentType = file.type || 'audio/webm';
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json(
{ error: `Unsupported audio format: ${contentType}` },
{ status: 400 }
);
}
// Generate unique filename
const ext = contentType.split('/')[1] || 'webm';
const filename = `${randomUUID()}.${ext}`;
const key = `voice/${filename}`;
// Convert to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to R2
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
// Return the URL through our proxy endpoint
const voiceUrl = `/api/upload/audio/${filename}`;
return NextResponse.json({ url: voiceUrl }, { status: 201 });
} catch (error) {
console.error('Error uploading audio:', error);
return NextResponse.json(
{ error: 'Failed to upload audio' },
{ status: 500 }
);
}
}
+10 -4
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ versionId: string }> };
@@ -74,6 +75,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/versions/[versionId]/comments
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'comment');
if (limited) return limited;
const session = await auth();
const { versionId } = await params;
@@ -149,10 +153,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
);
}
// Validate voice URL uses safe scheme
const voiceUrlError = validateOptionalUrl(voiceUrl, 'Voice URL');
if (voiceUrlError) {
return NextResponse.json({ error: voiceUrlError }, { status: 400 });
// Validate voice URL uses safe scheme (allow internal /api/ paths)
if (voiceUrl && !voiceUrl.startsWith('/api/')) {
const voiceUrlError = validateOptionalUrl(voiceUrl, 'Voice URL');
if (voiceUrlError) {
return NextResponse.json({ error: voiceUrlError }, { status: 400 });
}
}
const comment = await db.comment.create({
@@ -2,12 +2,16 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ workspaceId: string; memberId: string }> };
// PATCH /api/workspaces/[workspaceId]/members/[memberId] - Update member role
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth();
const { workspaceId, memberId } = await params;
@@ -64,6 +68,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/workspaces/[workspaceId]/members/[memberId] - Remove member
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth();
const { workspaceId, memberId } = await params;
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ workspaceId: string }> };
@@ -60,6 +61,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/workspaces/[workspaceId]/members - Invite a member
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'invite-member');
if (limited) return limited;
const session = await auth();
const { workspaceId } = await params;
+11
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupWorkspaceVoiceFiles } from '@/lib/r2-cleanup';
type RouteParams = { params: Promise<{ workspaceId: string }> };
@@ -81,6 +83,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// PATCH /api/workspaces/[workspaceId] - Update a workspace
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { workspaceId } = await params;
@@ -122,6 +127,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/workspaces/[workspaceId] - Delete a workspace
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { workspaceId } = await params;
@@ -142,6 +150,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
);
}
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupWorkspaceVoiceFiles(workspaceId);
await db.workspace.delete({ where: { id: workspaceId } });
return NextResponse.json({ success: true, message: 'Workspace deleted' });
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
// GET /api/workspaces - List all workspaces for the authenticated user
export async function GET() {
@@ -39,6 +40,9 @@ export async function GET() {
// POST /api/workspaces - Create a new workspace
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'create-workspace');
if (limited) return limited;
const session = await auth();
if (!session?.user?.id) {
+605 -84
View File
@@ -23,6 +23,7 @@ import {
Reply,
Pencil,
Trash2,
X,
ArrowUpRight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -66,6 +67,8 @@ interface Comment {
replies: {
id: string;
content: string | null;
voiceUrl: string | null;
voiceDuration: number | null;
createdAt: string;
author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null;
@@ -113,6 +116,19 @@ export default function WatchPage() {
const [commentText, setCommentText] = useState('');
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const [voiceProgress, setVoiceProgress] = useState(0);
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const voiceRafRef = useRef<number | null>(null);
const voiceKnownDurationRef = useRef<number>(0);
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
const [showResolved, setShowResolved] = useState(false);
@@ -120,6 +136,13 @@ export default function WatchPage() {
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [isReplyRecording, setIsReplyRecording] = useState(false);
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
const replyAudioChunksRef = useRef<Blob[]>([]);
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editText, setEditText] = useState('');
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
@@ -274,8 +297,9 @@ export default function WatchPage() {
[currentTime, duration, handleSeekToTimestamp]
);
const handleAddComment = useCallback(async () => {
if (!commentText.trim() || !activeVersion) return;
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
if (!voiceData && !commentText.trim()) return;
if (!activeVersion) return;
setIsSubmittingComment(true);
try {
@@ -283,8 +307,9 @@ export default function WatchPage() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: commentText,
content: voiceData ? commentText.trim() || null : commentText,
timestamp: selectedTimestamp ?? currentTime,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
}),
});
@@ -311,6 +336,189 @@ export default function WatchPage() {
}
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId]);
// Voice recording handlers
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
audioChunksRef.current = [];
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsRecording(true);
setRecordingTime(0);
recordingTimerRef.current = setInterval(() => {
setRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start recording:', err);
}
}, []);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
}, []);
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
setAudioBlob(null);
setRecordingTime(0);
}, []);
const submitVoiceComment = useCallback(async () => {
if (!audioBlob || !activeVersion) return;
setIsUploadingAudio(true);
try {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', {
method: 'POST',
body: formData,
});
if (!uploadRes.ok) {
throw new Error('Failed to upload audio');
}
const { url } = await uploadRes.json();
await handleAddComment({ url, duration: recordingTime });
setAudioBlob(null);
setRecordingTime(0);
} catch (err) {
console.error('Failed to submit voice comment:', err);
} finally {
setIsUploadingAudio(false);
}
}, [audioBlob, activeVersion, recordingTime, handleAddComment]);
// Voice playback
const stopVoiceTracking = useCallback(() => {
if (voiceRafRef.current) {
cancelAnimationFrame(voiceRafRef.current);
voiceRafRef.current = null;
}
}, []);
const startVoiceTracking = useCallback(() => {
stopVoiceTracking();
const tick = () => {
const audio = audioPlayerRef.current;
if (audio) {
const dur = isFinite(audio.duration) && audio.duration > 0
? audio.duration
: voiceKnownDurationRef.current;
if (dur > 0) {
setVoiceProgress((audio.currentTime / dur) * 100);
setVoiceCurrentTime(audio.currentTime);
}
}
voiceRafRef.current = requestAnimationFrame(tick);
};
voiceRafRef.current = requestAnimationFrame(tick);
}, [stopVoiceTracking]);
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
if (playingVoiceId === commentId) {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
return;
}
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
}
stopVoiceTracking();
voiceKnownDurationRef.current = knownDuration || 0;
const audio = new Audio(voiceUrl);
audio.playbackRate = voicePlaybackRate;
audioPlayerRef.current = audio;
setPlayingVoiceId(commentId);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audio.onplay = () => {
startVoiceTracking();
};
audio.onended = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onerror = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.play();
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
const toggleVoiceSpeed = useCallback(() => {
setVoicePlaybackRate((prev) => {
const next = prev === 1 ? 2 : 1;
if (audioPlayerRef.current) {
audioPlayerRef.current.playbackRate = next;
}
return next;
});
}, []);
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
}
};
}, []);
const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => {
try {
@@ -346,17 +554,19 @@ export default function WatchPage() {
);
// Reply to a comment
const handleReplyComment = useCallback(async (parentId: string) => {
if (!replyText.trim() || !activeVersion) return;
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
if (!voiceData && !replyText.trim()) return;
if (!activeVersion) return;
setIsSubmittingReply(true);
try {
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: replyText,
content: voiceData ? replyText.trim() || null : replyText,
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
}),
});
if (res.ok) {
@@ -381,6 +591,8 @@ export default function WatchPage() {
});
setReplyText('');
setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
}
} catch (err) {
console.error('Failed to reply:', err);
@@ -389,6 +601,73 @@ export default function WatchPage() {
}
}, [replyText, activeVersion, activeVersionId, comments, currentTime]);
// Voice recording for replies
const startReplyRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
replyAudioChunksRef.current = [];
replyMediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
setReplyAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (replyRecordingTimerRef.current) {
clearInterval(replyRecordingTimerRef.current);
replyRecordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsReplyRecording(true);
setReplyRecordingTime(0);
replyRecordingTimerRef.current = setInterval(() => {
setReplyRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start reply recording:', err);
}
}, []);
const stopReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
}, []);
const cancelReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
}, []);
const submitVoiceReply = useCallback(async (parentId: string) => {
if (!replyAudioBlob || !activeVersion) return;
setIsUploadingReplyAudio(true);
try {
const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio');
const { url } = await uploadRes.json();
await handleReplyComment(parentId, { url, duration: replyRecordingTime });
} catch (err) {
console.error('Failed to submit voice reply:', err);
} finally {
setIsUploadingReplyAudio(false);
}
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]);
// Edit a comment
const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim()) return;
@@ -830,15 +1109,37 @@ export default function WatchPage() {
{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">
<Play className="h-4 w-4" />
<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-1 bg-primary/30 rounded">
<div className="w-0 h-full bg-primary rounded" />
<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">
{formatTime(comment.voiceDuration || 0)}
<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>
)}
@@ -933,7 +1234,42 @@ export default function WatchPage() {
</div>
</div>
) : (
<p className="text-sm">{reply.content}</p>
reply.content && <p className="text-sm">{reply.content}</p>
)}
{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>
);
@@ -944,41 +1280,126 @@ export default function WatchPage() {
{/* Inline reply form */}
{isReplying && (
<div className="mt-3 pl-3 border-l-2">
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Write a reply..."
rows={2}
className="resize-none text-sm mb-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleReplyComment(comment.id);
}
if (e.key === 'Escape') {
setReplyingTo(null);
setReplyText('');
}
}}
/>
<div className="flex gap-1">
<Button
size="sm"
onClick={() => handleReplyComment(comment.id)}
disabled={!replyText.trim() || isSubmittingReply}
className="h-7 text-xs"
>
{isSubmittingReply ? <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>
{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>
<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">
<Button
size="sm"
onClick={() => submitVoiceReply(comment.id)}
disabled={isUploadingReplyAudio}
className="h-7 text-xs"
>
{isUploadingReplyAudio ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Voice Reply'}
</Button>
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</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('');
}
}}
/>
<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>
</div>
<div className="flex gap-1 mt-1">
<Button
size="sm"
onClick={() => handleReplyComment(comment.id)}
disabled={!replyText.trim() || isSubmittingReply}
className="h-7 text-xs"
>
{isSubmittingReply ? <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>
)}
@@ -1002,52 +1423,152 @@ export default function WatchPage() {
<div className="shrink-0 p-4 border-t bg-background">
<div className="flex items-center gap-2 mb-2">
<Button
variant="outline"
variant={selectedTimestamp !== null ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedTimestamp(currentTime)}
className={cn(selectedTimestamp !== null && 'border-primary')}
onClick={() => {
if (selectedTimestamp !== null) {
setSelectedTimestamp(null);
} else {
setSelectedTimestamp(currentTime);
}
}}
>
<Clock className="h-4 w-4 mr-1" />
{selectedTimestamp !== null ? formatTime(selectedTimestamp) : formatTime(currentTime)}
{selectedTimestamp !== null && <X className="h-3 w-3 ml-1" />}
</Button>
<span className="text-xs text-muted-foreground">Pin to this time</span>
<span className="text-xs text-muted-foreground">
{selectedTimestamp !== null ? 'Pinned — click to unpin' : 'Pin to this time'}
</span>
</div>
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={handleAddComment}
disabled={!commentText.trim() || isSubmittingComment}
>
{isSubmittingComment ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
{/* Recording state UI */}
{isRecording ? (
<div className="flex items-center gap-3 p-3 bg-destructive/10 border border-destructive/30 rounded-lg">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
<span className="text-sm font-medium text-destructive">
Recording {formatTime(recordingTime)}
</span>
<div className="flex-1" />
<Button size="sm" variant="destructive" onClick={stopRecording}>
Stop
</Button>
<Button
size="icon"
variant={isRecording ? 'destructive' : 'outline'}
onClick={() => setIsRecording(!isRecording)}
>
<Mic className={cn('h-4 w-4', isRecording && 'animate-pulse')} />
<Button size="sm" variant="ghost" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
) : audioBlob ? (
<div className="space-y-2">
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => {
const url = URL.createObjectURL(audioBlob);
playVoice('preview', url, recordingTime);
}}
>
{playingVoiceId === 'preview' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'preview' ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
: formatTime(recordingTime)}
</span>
{playingVoiceId === 'preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={cancelRecording}
>
<X className="h-4 w-4" />
</Button>
</div>
<Textarea
placeholder="Add a note to your voice comment (optional)..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={1}
className="resize-none text-sm"
/>
<Button
size="sm"
onClick={submitVoiceComment}
disabled={isUploadingAudio}
className="w-full"
>
{isUploadingAudio ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Uploading...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Voice Comment
</>
)}
</Button>
</div>
) : (
<>
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={() => handleAddComment()}
disabled={!commentText.trim() || isSubmittingComment}
>
{isSubmittingComment ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
onClick={startRecording}
title="Record voice comment"
>
<Mic className="h-4 w-4" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
</>
)}
</div>
</div>
</div>