diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index da936e8..a3ef4a9 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -4,7 +4,7 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { getCachedTotalStorage } from '@/lib/admin-stats';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Users, Folder, Video, MessageSquare, Mic, HardDrive } from 'lucide-react';
+import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon } from 'lucide-react';
export const metadata: Metadata = {
title: 'Admin Dashboard | OpenFrame',
@@ -34,6 +34,7 @@ export default async function AdminDashboardPage() {
totalVideos,
totalComments,
totalVoiceComments,
+ totalImageComments,
] = await Promise.all([
db.user.count(),
db.project.count(),
@@ -42,6 +43,9 @@ export default async function AdminDashboardPage() {
db.comment.count({
where: { voiceUrl: { not: null } },
}),
+ db.comment.count({
+ where: { imageUrl: { not: null } },
+ }),
]);
// 2. Storage Stats (Cached)
@@ -101,6 +105,15 @@ export default async function AdminDashboardPage() {
{totalVoiceComments}
+
+
+ Image Attachments
+
+
+
+ {totalImageComments}
+
+
Cloudflare R2 Storage
diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx
index abc3889..bd4c018 100644
--- a/app/admin/users/page.tsx
+++ b/app/admin/users/page.tsx
@@ -2,7 +2,8 @@ import { Metadata } from 'next';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
-import { getCachedUserVoiceStorage } from '@/lib/admin-stats';
+import { getCachedUserMediaStorage } from '@/lib/admin-stats';
+import { HardDrive } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
@@ -69,8 +70,8 @@ export default async function AdminUsersPage({
const totalPages = Math.ceil(totalUsers / pageSize);
- // Determine voice storage per user (Cached)
- const userStorage = await getCachedUserVoiceStorage();
+ // Determine media storage per user (Cached)
+ const userStorage = await getCachedUserMediaStorage();
return (
@@ -95,7 +96,7 @@ export default async function AdminUsersPage({
Workspaces Owned
Projects Owned
Total Comments
-
Voice Storage
+
Media Storage
@@ -120,8 +121,17 @@ export default async function AdminUsersPage({
{user._count.ownedWorkspaces}
{user._count.projects}
{user._count.comments}
-
- {formatBytes(userStorage[user.id] || 0)}
+
+
+ {formatBytes(userStorage[user.id]?.total || 0)}
+ {(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && (
+
+ {userStorage[user.id]?.voice > 0 && 🎤 {formatBytes(userStorage[user.id]?.voice)}}
+ {userStorage[user.id]?.voice > 0 && userStorage[user.id]?.image > 0 && •}
+ {userStorage[user.id]?.image > 0 && 🖼️ {formatBytes(userStorage[user.id]?.image)}}
+
+ )}
+
))
diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts
index 654bc0c..580511b 100644
--- a/app/api/admin/stats/route.ts
+++ b/app/api/admin/stats/route.ts
@@ -17,6 +17,7 @@ export async function GET() {
totalVideos,
totalComments,
totalVoiceComments,
+ totalImageComments,
] = await Promise.all([
db.user.count(),
db.project.count(),
@@ -28,6 +29,13 @@ export async function GET() {
not: null,
}
}
+ }),
+ db.comment.count({
+ where: {
+ imageUrl: {
+ not: null,
+ }
+ }
})
]);
@@ -40,6 +48,7 @@ export async function GET() {
totalVideos,
totalComments,
totalVoiceComments,
+ totalImageComments,
totalStorageBytes,
});
} catch (error) {
diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts
index 91c2f0c..df27073 100644
--- a/app/api/comments/[commentId]/route.ts
+++ b/app/api/comments/[commentId]/route.ts
@@ -16,12 +16,46 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const comment = await db.comment.findUnique({
where: { id: commentId },
- include: {
+ select: {
+ id: true,
+ content: true,
+ timestamp: true,
+ timestampEnd: true,
+ createdAt: true,
+ updatedAt: true,
+ isResolved: true,
+ resolvedAt: true,
+ voiceUrl: true,
+ voiceDuration: true,
+ imageUrl: true,
+ parentId: true,
+ authorId: true,
+ tagId: true,
+ versionId: true,
+ guestName: true,
author: { select: { id: true, name: true, image: true } },
+ tag: { select: { id: true, name: true, color: true } },
replies: {
orderBy: { createdAt: 'asc' },
- include: {
+ select: {
+ id: true,
+ content: true,
+ timestamp: true,
+ timestampEnd: true,
+ createdAt: true,
+ updatedAt: true,
+ isResolved: true,
+ resolvedAt: true,
+ voiceUrl: true,
+ voiceDuration: true,
+ imageUrl: true,
+ parentId: true,
+ authorId: true,
+ tagId: true,
+ versionId: true,
+ guestName: true,
author: { select: { id: true, name: true, image: true } },
+ tag: { select: { id: true, name: true, color: true } },
},
},
version: {
@@ -155,7 +189,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
}
const updateData: Record = {};
- if (content !== undefined) updateData.content = content.trim();
+ if (content !== undefined && typeof content === 'string') updateData.content = content.trim();
if (tagId !== undefined) updateData.tagId = tagId;
if (isResolved !== undefined) {
updateData.isResolved = isResolved;
@@ -201,7 +235,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const comment = await db.comment.findUnique({
where: { id: commentId },
include: {
- replies: { select: { voiceUrl: true } },
+ replies: { select: { voiceUrl: true, imageUrl: true } },
},
});
@@ -215,27 +249,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('You can only delete your own comments');
}
- // Collect all voice URLs to delete from R2 (comment + its replies)
- const voiceUrls: string[] = [];
- if (comment.voiceUrl) voiceUrls.push(comment.voiceUrl);
+ // Collect all media URLs to delete from R2 (comment + its replies)
+ const mediaUrls: string[] = [];
+ if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl);
+ if (comment.imageUrl) mediaUrls.push(comment.imageUrl);
for (const reply of comment.replies) {
- if (reply.voiceUrl) voiceUrls.push(reply.voiceUrl);
+ if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl);
+ if (reply.imageUrl) mediaUrls.push(reply.imageUrl);
}
await db.comment.delete({ where: { id: commentId } });
- // Clean up audio files from R2 (best-effort, don't block on failure)
+ // Clean up media files from R2 (best-effort, don't block on failure)
const AUDIO_PREFIX = '/api/upload/audio/';
- for (const url of voiceUrls) {
+ const IMAGE_PREFIX = '/api/upload/image/';
+ for (const url of mediaUrls) {
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) {
+ let key: string | null = null;
+ if (url.includes(AUDIO_PREFIX)) {
+ const filename = url.slice(url.indexOf(AUDIO_PREFIX) + AUDIO_PREFIX.length);
+ if (filename) key = `voice/${filename}`;
+ } else if (url.includes(IMAGE_PREFIX)) {
+ const filename = url.slice(url.indexOf(IMAGE_PREFIX) + IMAGE_PREFIX.length);
+ if (filename) key = `images/${filename}`;
+ }
+
+ if (key) {
await r2Client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
- Key: `voice/${filename}`,
+ Key: key,
})
);
}
diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts
index ee4857f..c595394 100644
--- a/app/api/projects/[projectId]/route.ts
+++ b/app/api/projects/[projectId]/route.ts
@@ -3,7 +3,7 @@ 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';
+import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -52,11 +52,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
const { projectId } = await params;
-
+
// Parse pagination params
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
- const offset = parseInt(searchParams.get('offset') || '0');
+ const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'));
const project = await db.project.findUnique({
where: { id: projectId },
@@ -197,7 +197,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Clean up voice files from R2 before cascade delete removes comment rows
- await cleanupProjectVoiceFiles(projectId);
+ await cleanupProjectMediaFiles(projectId);
await db.project.delete({ where: { id: projectId } });
diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts
index 050b010..d7ffa6e 100644
--- a/app/api/projects/[projectId]/videos/[videoId]/route.ts
+++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
-import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup';
+import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -18,7 +18,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// Parse query params for pagination and options
const searchParams = request.nextUrl.searchParams;
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
- const commentOffset = parseInt(searchParams.get('commentOffset') || '0');
+ const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
const includeReplies = searchParams.get('includeReplies') === 'true';
const video = await db.video.findFirst({
@@ -32,20 +32,54 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
orderBy: { timestamp: 'asc' },
skip: commentOffset,
take: commentLimit,
- include: {
+ select: {
+ id: true,
+ content: true,
+ timestamp: true,
+ timestampEnd: true,
+ createdAt: true,
+ updatedAt: true,
+ isResolved: true,
+ resolvedAt: true,
+ voiceUrl: true,
+ voiceDuration: true,
+ imageUrl: true,
+ parentId: true,
+ authorId: true,
+ tagId: true,
+ versionId: true,
+ guestName: true,
+ // guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
...(includeReplies ? {
replies: {
orderBy: { createdAt: 'asc' },
- include: {
+ select: {
+ id: true,
+ content: true,
+ timestamp: true,
+ timestampEnd: true,
+ createdAt: true,
+ updatedAt: true,
+ isResolved: true,
+ resolvedAt: true,
+ voiceUrl: true,
+ voiceDuration: true,
+ imageUrl: true,
+ parentId: true,
+ authorId: true,
+ tagId: true,
+ versionId: true,
+ guestName: true,
+ // guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
} : {}),
},
- where: { parentId: null }, // Only top-level comments
+ where: { parentId: null },
},
_count: { select: { comments: true } },
},
@@ -194,7 +228,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Clean up voice files from R2 before cascade delete removes comment rows
- await cleanupVideoVoiceFiles(videoId);
+ await cleanupVideoMediaFiles(videoId);
await db.video.delete({ where: { id: videoId } });
diff --git a/app/api/upload/audio/[filename]/route.ts b/app/api/upload/audio/[filename]/route.ts
index d46c147..b532012 100644
--- a/app/api/upload/audio/[filename]/route.ts
+++ b/app/api/upload/audio/[filename]/route.ts
@@ -62,8 +62,8 @@ export async function GET(
// Convert stream to Uint8Array
const chunks: Uint8Array[] = [];
- // @ts-expect-error - body is an iterable
- for await (const chunk of body) {
+ const asyncIterable = body as AsyncIterable;
+ for await (const chunk of asyncIterable) {
chunks.push(chunk);
}
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
diff --git a/app/api/upload/image/[filename]/route.ts b/app/api/upload/image/[filename]/route.ts
new file mode 100644
index 0000000..947b699
--- /dev/null
+++ b/app/api/upload/image/[filename]/route.ts
@@ -0,0 +1,88 @@
+import { NextResponse } from 'next/server';
+import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
+import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
+import { apiErrors } from '@/lib/api-response';
+
+// 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;
+
+const CONTENT_TYPE_MAP: Record = {
+ jpeg: 'image/jpeg',
+ jpg: 'image/jpeg',
+ png: 'image/png',
+ webp: 'image/webp',
+ gif: 'image/gif',
+ svg: 'image/svg+xml',
+};
+
+function getContentType(filename: string): string {
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
+ return CONTENT_TYPE_MAP[ext] || 'image/jpeg';
+}
+
+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 apiErrors.badRequest('Invalid filename');
+ }
+
+ const key = `images/${filename}`;
+
+ // Get file metadata to determine content type
+ const headResponse = await r2Client.send(
+ new HeadObjectCommand({
+ Bucket: R2_BUCKET_NAME,
+ Key: key,
+ })
+ );
+
+ const contentType = headResponse.ContentType || getContentType(filename);
+
+ const objectResponse = await r2Client.send(
+ new GetObjectCommand({
+ Bucket: R2_BUCKET_NAME,
+ Key: key,
+ })
+ );
+
+ const body = objectResponse.Body;
+ if (!body) {
+ return apiErrors.internalError('Empty file');
+ }
+
+ const chunks: Uint8Array[] = [];
+ // @ts-expect-error - body is an iterable
+ for await (const chunk of body) {
+ chunks.push(chunk);
+ }
+ const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
+ let offset = 0;
+ for (const chunk of chunks) {
+ uint8Array.set(chunk, offset);
+ offset += chunk.length;
+ }
+
+ return new NextResponse(uint8Array, {
+ status: 200,
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ 'Accept-Ranges': 'bytes',
+ },
+ });
+ } catch (error: unknown) {
+ const errorName = error instanceof Error ? error.name : '';
+ if (errorName === 'NoSuchKey') {
+ return apiErrors.notFound('File');
+ }
+ console.error('Error serving image:', error);
+ return apiErrors.internalError('Failed to retrieve image');
+ }
+}
+
diff --git a/app/api/upload/image/route.ts b/app/api/upload/image/route.ts
new file mode 100644
index 0000000..0d08798
--- /dev/null
+++ b/app/api/upload/image/route.ts
@@ -0,0 +1,84 @@
+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';
+import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
+
+const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+const ALLOWED_TYPES = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+ 'image/gif',
+ 'image/svg+xml'
+];
+
+export async function POST(request: Request) {
+ try {
+ // Check Content-Length header BEFORE loading the file
+ const contentLength = request.headers.get('content-length');
+ if (contentLength) {
+ const fileSize = parseInt(contentLength, 10);
+ if (isNaN(fileSize) || fileSize > MAX_FILE_SIZE) {
+ return apiErrors.badRequest('File too large. Maximum size is 10MB.');
+ }
+ }
+
+ // Rate limit
+ const limited = await rateLimit(request, 'image-upload');
+ if (limited) return limited;
+
+ // Require authentication
+ const session = await auth();
+ if (!session?.user?.id) {
+ return apiErrors.unauthorized();
+ }
+
+ const formData = await request.formData();
+ const file = formData.get('image') as File | null;
+
+ if (!file) {
+ return apiErrors.badRequest('No image file provided');
+ }
+
+ // Double-check file size (defense in depth - Content-Length can be spoofed)
+ if (file.size > MAX_FILE_SIZE) {
+ return apiErrors.badRequest('File too large. Maximum size is 10MB.');
+ }
+
+ // Check content type
+ const contentType = file.type;
+ if (!ALLOWED_TYPES.includes(contentType)) {
+ return apiErrors.badRequest(`Unsupported image format: ${contentType}`);
+ }
+
+ // Generate unique filename
+ const ext = contentType.split('/')[1] === 'svg+xml' ? 'svg' : contentType.split('/')[1] || 'jpeg';
+ const filename = `${randomUUID()}.${ext}`;
+ const key = `images/${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 imageUrl = `/api/upload/image/${filename}`;
+
+ const response = successResponse({ url: imageUrl }, 201);
+ return withCacheControl(response, 'public, max-age=31536000, immutable');
+ } catch (error) {
+ console.error('Error uploading image:', error);
+ return apiErrors.internalError('Failed to upload image');
+ }
+}
diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts
index 63244f8..feb201f 100644
--- a/app/api/versions/[versionId]/comments/route.ts
+++ b/app/api/versions/[versionId]/comments/route.ts
@@ -71,12 +71,44 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
...(includeResolved ? {} : { isResolved: false }),
},
orderBy: { timestamp: 'asc' },
- include: {
+ select: {
+ id: true,
+ content: true,
+ timestamp: true,
+ timestampEnd: true,
+ createdAt: true,
+ updatedAt: true,
+ isResolved: true,
+ resolvedAt: true,
+ voiceUrl: true,
+ voiceDuration: true,
+ imageUrl: true,
+ parentId: true,
+ authorId: true,
+ tagId: true,
+ versionId: true,
+ guestName: true,
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: {
orderBy: { createdAt: 'asc' },
- include: {
+ select: {
+ id: true,
+ content: true,
+ timestamp: true,
+ timestampEnd: true,
+ createdAt: true,
+ updatedAt: true,
+ isResolved: true,
+ resolvedAt: true,
+ voiceUrl: true,
+ voiceDuration: true,
+ imageUrl: true,
+ parentId: true,
+ authorId: true,
+ tagId: true,
+ versionId: true,
+ guestName: true,
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
@@ -152,15 +184,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
const body = await request.json();
- const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId } = body;
+ const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId, imageUrl } = body;
// Validate required fields
if (timestamp === undefined || timestamp === null) {
return apiErrors.badRequest('Timestamp is required');
}
- if (!content && !voiceUrl) {
- return apiErrors.badRequest('Either content or voice recording is required');
+ const parsedTimestamp = parseFloat(timestamp);
+ if (isNaN(parsedTimestamp)) {
+ return apiErrors.badRequest('Timestamp must be a valid number');
+ }
+
+ if (!content && !voiceUrl && !imageUrl) {
+ return apiErrors.badRequest('Either content, a voice recording, or an image attachment is required');
}
// If replying, verify parent exists in same version
@@ -187,14 +224,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
}
+ if (imageUrl && !imageUrl.startsWith('/api/')) {
+ const imageUrlError = validateOptionalUrl(imageUrl, 'Image URL');
+ if (imageUrlError) {
+ return apiErrors.badRequest(imageUrlError);
+ }
+ }
+
const comment = await db.comment.create({
data: {
content: content?.trim() || null,
- timestamp: parseFloat(timestamp),
+ timestamp: parsedTimestamp,
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
parentId: parentId || null,
voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null,
+ imageUrl: imageUrl || null,
authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null,
guestEmail: isGuest ? guestEmail : null,
@@ -234,7 +279,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
projectName: project.name,
videoTitle,
replyAuthor: commentAuthorName,
- replyText: content?.trim() || '(voice note)',
+ replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone',
timestamp: ts,
url: `${baseUrl}/watch/${version.video.id}`,
@@ -245,7 +290,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
projectName: project.name,
videoTitle,
commentAuthor: commentAuthorName,
- commentText: content?.trim() || '(voice note)',
+ commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
timestamp: ts,
url: `${baseUrl}/watch/${version.video.id}`,
}).catch((err) => console.error('Notification failed:', err));
diff --git a/app/api/workspaces/[workspaceId]/route.ts b/app/api/workspaces/[workspaceId]/route.ts
index 97630f4..6ab178a 100644
--- a/app/api/workspaces/[workspaceId]/route.ts
+++ b/app/api/workspaces/[workspaceId]/route.ts
@@ -2,7 +2,7 @@ import { NextRequest } 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';
+import { cleanupWorkspaceMediaFiles } from '@/lib/r2-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ workspaceId: string }> };
@@ -145,7 +145,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Clean up voice files from R2 before cascade delete removes comment rows
- await cleanupWorkspaceVoiceFiles(workspaceId);
+ await cleanupWorkspaceMediaFiles(workspaceId);
await db.workspace.delete({ where: { id: workspaceId } });
diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx
index 100e97a..cbd850d 100644
--- a/components/video-page-content.tsx
+++ b/components/video-page-content.tsx
@@ -38,6 +38,8 @@ import {
User,
Maximize,
Minimize,
+ Image as ImageIcon,
+ Download,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -102,6 +104,7 @@ interface Comment {
timestamp: number;
voiceUrl: string | null;
voiceDuration: number | null;
+ imageUrl: string | null;
isResolved: boolean;
createdAt: string;
author: { id: string; name: string | null; image: string | null } | null;
@@ -112,6 +115,7 @@ interface Comment {
content: string | null;
voiceUrl: string | null;
voiceDuration: number | null;
+ imageUrl: string | null;
createdAt: string;
author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null;
@@ -185,6 +189,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState(null);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
+ const [imageBlob, setImageBlob] = useState(null);
+ const [isUploadingImage, setIsUploadingImage] = useState(false);
+ const imageInputRef = useRef(null);
const [playingVoiceId, setPlayingVoiceId] = useState(null);
const [voiceProgress, setVoiceProgress] = useState(0);
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
@@ -220,6 +227,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
const [replyAudioBlob, setReplyAudioBlob] = useState(null);
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
+ const [replyImageBlob, setReplyImageBlob] = useState(null);
+ const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false);
+ const replyImageInputRef = useRef(null);
const replyMediaRecorderRef = useRef(null);
const replyAudioChunksRef = useRef([]);
const replyRecordingTimerRef = useRef | null>(null);
@@ -229,6 +239,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [deletingCommentId, setDeletingCommentId] = useState(null);
const isMutatingRef = useRef(false);
+ const [previewImage, setPreviewImage] = useState(null);
const [guestName, setGuestName] = useState('');
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
@@ -893,17 +904,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}
}, [isDragging, currentTime, handleSeekToTimestamp]);
- const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
- if (!voiceData && !commentText.trim()) return;
+ const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
+ if (!voiceData && !imageBlob && !commentText.trim()) return;
if (!activeVersion) return;
const tempId = `temp-${Date.now()}`;
const optimisticComment: Comment = {
id: tempId,
- content: voiceData ? commentText.trim() || null : commentText,
+ content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
timestamp: selectedTimestamp ?? currentTime,
voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null,
+ imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
isResolved: false,
createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
@@ -928,18 +940,37 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setSelectedTimestamp(null);
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
setAudioBlob(null);
+ setImageBlob(null);
setIsSubmittingComment(true);
isMutatingRef.current = true;
try {
+ let imageData: { url: string } | undefined;
+
+ if (imageBlob) {
+ setIsUploadingImage(true);
+ const imageFormData = new FormData();
+ imageFormData.append('image', imageBlob);
+
+ const imageRes = await fetch('/api/upload/image', {
+ method: 'POST',
+ body: imageFormData,
+ });
+
+ if (!imageRes.ok) throw new Error('Failed to upload image');
+ const imageDataResponse = await imageRes.json();
+ imageData = { url: imageDataResponse.data.url };
+ }
+
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- content: voiceData ? commentText.trim() || null : commentText,
+ content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
timestamp: selectedTimestamp ?? currentTime,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
+ ...(imageData && { imageUrl: imageData.url }),
...(isGuest && guestName && { guestName }),
...(selectedTagId && { tagId: selectedTagId }),
}),
@@ -988,9 +1019,55 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
toast.error('Failed to add comment');
} finally {
setIsSubmittingComment(false);
+ setIsUploadingImage(false);
isMutatingRef.current = false;
}
- }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]);
+ }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags, imageBlob]);
+
+ const handleImageSelect = useCallback((e: React.ChangeEvent, isReply: boolean = false) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (!file.type.startsWith('image/')) {
+ toast.error('Please select an image file');
+ return;
+ }
+
+ if (file.size > 10 * 1024 * 1024) {
+ toast.error('Image must be less than 10MB');
+ return;
+ }
+
+ if (isReply) {
+ setReplyImageBlob(file);
+ } else {
+ setImageBlob(file);
+ }
+ }, []);
+
+ const handlePaste = useCallback((e: React.ClipboardEvent, isReply: boolean = false) => {
+ const items = e.clipboardData?.items;
+ if (!items) return;
+
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].type.indexOf('image') !== -1) {
+ const file = items[i].getAsFile();
+ if (file) {
+ if (file.size > 10 * 1024 * 1024) {
+ toast.error('Image must be less than 10MB');
+ return;
+ }
+ if (isReply) {
+ setReplyImageBlob(file);
+ } else {
+ setImageBlob(file);
+ }
+ e.preventDefault();
+ break;
+ }
+ }
+ }
+ }, []);
const startRecording = useCallback(async () => {
try {
@@ -1174,6 +1251,46 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
};
}, []);
+ const submitCommentWithMedia = useCallback(async () => {
+ if (!activeVersion) return;
+
+ // If we only have audio, handle it via submitVoiceComment for backwards compatibility conceptually
+ if (audioBlob && !imageBlob && !commentText.trim()) {
+ submitVoiceComment();
+ return;
+ }
+
+ if (audioBlob) setIsUploadingAudio(true);
+ if (imageBlob) setIsUploadingImage(true);
+
+ try {
+ let voiceData: { url: string; duration: number } | undefined;
+ let imageData: { url: string } | undefined;
+
+ if (audioBlob) {
+ 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 uploadData = await uploadRes.json();
+ voiceData = { url: uploadData.data.url, duration: recordingTime };
+ }
+
+ await handleAddComment(voiceData, imageData); // Image is uploaded inside handleAddComment for both text/image cases
+
+ setAudioBlob(null);
+ setRecordingTime(0);
+ setImageBlob(null);
+ if (imageInputRef.current) imageInputRef.current.value = '';
+ } catch (err) {
+ console.error('Failed to submit comment with media:', err);
+ toast.error('Failed to upload media');
+ } finally {
+ setIsUploadingAudio(false);
+ setIsUploadingImage(false);
+ }
+ }, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment]);
+
const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => {
isMutatingRef.current = true;
@@ -1245,17 +1362,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
[activeVersionId]
);
- const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
- if (!voiceData && !replyText.trim()) return;
+ const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
+ if (!voiceData && !replyImageBlob && !replyText.trim()) return;
if (!activeVersion) return;
const tempId = `temp-reply-${Date.now()}`;
const parentComment = comments.find((c) => c.id === parentId);
const optimisticReply = {
id: tempId,
- content: voiceData ? replyText.trim() || null : replyText,
+ content: (voiceData || replyImageBlob) ? replyText.trim() || null : replyText,
voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null,
+ imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
guestName: isGuest ? guestName : null,
@@ -1285,19 +1403,38 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
+ setReplyImageBlob(null);
setIsSubmittingReply(true);
isMutatingRef.current = true;
try {
+ let submittedImageData: { url: string } | undefined = imageData;
+
+ if (replyImageBlob && !imageData) {
+ setIsUploadingReplyImage(true);
+ const imageFormData = new FormData();
+ imageFormData.append('image', replyImageBlob);
+
+ const imageRes = await fetch('/api/upload/image', {
+ method: 'POST',
+ body: imageFormData,
+ });
+
+ if (!imageRes.ok) throw new Error('Failed to upload image reply');
+ const imageDataResponse = await imageRes.json();
+ submittedImageData = { url: imageDataResponse.data.url };
+ }
+
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- content: voiceData ? replyText.trim() || null : replyText,
+ content: (voiceData || submittedImageData) ? replyText.trim() || null : replyText,
timestamp: parentComment?.timestamp ?? currentTime,
parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
+ ...(submittedImageData && { imageUrl: submittedImageData.url }),
...(isGuest && guestName && { guestName }),
}),
});
@@ -1366,9 +1503,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
toast.error('Failed to add reply');
} finally {
setIsSubmittingReply(false);
+ setIsUploadingReplyImage(false);
isMutatingRef.current = false;
}
- }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]);
+ }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, replyImageBlob]);
const startReplyRecording = useCallback(async () => {
try {
@@ -1437,6 +1575,44 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]);
+ const submitReplyWithMedia = useCallback(async (parentId: string) => {
+ if (!activeVersion) return;
+
+ if (replyAudioBlob && !replyImageBlob && !replyText.trim()) {
+ submitVoiceReply(parentId);
+ return;
+ }
+
+ if (replyAudioBlob) setIsUploadingReplyAudio(true);
+ if (replyImageBlob) setIsUploadingReplyImage(true);
+
+ try {
+ let voiceData: { url: string; duration: number } | undefined;
+
+ if (replyAudioBlob) {
+ 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 reply');
+ const uploadData = await uploadRes.json();
+ voiceData = { url: uploadData.data.url, duration: replyRecordingTime };
+ }
+
+ await handleReplyComment(parentId, voiceData);
+
+ setReplyAudioBlob(null);
+ setReplyRecordingTime(0);
+ setReplyImageBlob(null);
+ if (replyImageInputRef.current) replyImageInputRef.current.value = '';
+ } catch (err) {
+ console.error('Failed to submit reply with media:', err);
+ toast.error('Failed to upload media');
+ } finally {
+ setIsUploadingReplyAudio(false);
+ setIsUploadingReplyImage(false);
+ }
+ }, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment]);
+
const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim()) return;
setIsSubmittingEdit(true);
@@ -1502,7 +1678,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
.filter((c) => c.id !== commentId)
.map((c) => ({
...c,
- replies: c.replies.filter((r) => r.id !== commentId),
+ replies: c.replies?.filter((r) => r.id !== commentId) || [],
})),
}
: v
@@ -2381,7 +2557,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
) : (
- comment.content && {comment.content}
+
+ {comment.content &&
{comment.content}
}
+ {comment.imageUrl && (
+
setPreviewImage(comment.imageUrl)}
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ )}
+
)}
{comment.voiceUrl && (
@@ -2524,7 +2711,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
) : (
- reply.content && {reply.content}
+
+ {reply.content &&
{reply.content}
}
+ {reply.imageUrl && (
+
setPreviewImage(reply.imageUrl)}
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ )}
+
)}
{reply.voiceUrl && (
@@ -2620,6 +2818,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
+
+ {replyImageBlob && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
})
+
+
+
+
+ )}
+