mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
fix: address security vulnerabilities and add image attachments
- Fix type confusion vulnerability in comment content updates - Validate pagination offsets to prevent negative values - Validate timestamp is a valid number before parsing - Exclude guestEmail from comment API responses for privacy - Fix TypeScript error in audio upload route - Add image attachment support for comments with upload API - Update admin dashboard to track image attachments - Rename cleanup functions to handle both voice and image media
This commit is contained in:
+14
-1
@@ -4,7 +4,7 @@ import { auth } from '@/lib/auth';
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getCachedTotalStorage } from '@/lib/admin-stats';
|
import { getCachedTotalStorage } from '@/lib/admin-stats';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: 'Admin Dashboard | OpenFrame',
|
title: 'Admin Dashboard | OpenFrame',
|
||||||
@@ -34,6 +34,7 @@ export default async function AdminDashboardPage() {
|
|||||||
totalVideos,
|
totalVideos,
|
||||||
totalComments,
|
totalComments,
|
||||||
totalVoiceComments,
|
totalVoiceComments,
|
||||||
|
totalImageComments,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.user.count(),
|
db.user.count(),
|
||||||
db.project.count(),
|
db.project.count(),
|
||||||
@@ -42,6 +43,9 @@ export default async function AdminDashboardPage() {
|
|||||||
db.comment.count({
|
db.comment.count({
|
||||||
where: { voiceUrl: { not: null } },
|
where: { voiceUrl: { not: null } },
|
||||||
}),
|
}),
|
||||||
|
db.comment.count({
|
||||||
|
where: { imageUrl: { not: null } },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. Storage Stats (Cached)
|
// 2. Storage Stats (Cached)
|
||||||
@@ -101,6 +105,15 @@ export default async function AdminDashboardPage() {
|
|||||||
<div className="text-2xl font-bold">{totalVoiceComments}</div>
|
<div className="text-2xl font-bold">{totalVoiceComments}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Image Attachments</CardTitle>
|
||||||
|
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalImageComments}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Cloudflare R2 Storage</CardTitle>
|
<CardTitle className="text-sm font-medium">Cloudflare R2 Storage</CardTitle>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Metadata } from 'next';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { redirect } from 'next/navigation';
|
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 Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -69,8 +70,8 @@ export default async function AdminUsersPage({
|
|||||||
|
|
||||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||||
|
|
||||||
// Determine voice storage per user (Cached)
|
// Determine media storage per user (Cached)
|
||||||
const userStorage = await getCachedUserVoiceStorage();
|
const userStorage = await getCachedUserMediaStorage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||||
@@ -95,7 +96,7 @@ export default async function AdminUsersPage({
|
|||||||
<TableHead className="text-center">Workspaces Owned</TableHead>
|
<TableHead className="text-center">Workspaces Owned</TableHead>
|
||||||
<TableHead className="text-center">Projects Owned</TableHead>
|
<TableHead className="text-center">Projects Owned</TableHead>
|
||||||
<TableHead className="text-center">Total Comments</TableHead>
|
<TableHead className="text-center">Total Comments</TableHead>
|
||||||
<TableHead className="text-right">Voice Storage</TableHead>
|
<TableHead className="text-right">Media Storage</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -120,8 +121,17 @@ export default async function AdminUsersPage({
|
|||||||
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
|
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
|
||||||
<TableCell className="text-center">{user._count.projects}</TableCell>
|
<TableCell className="text-center">{user._count.projects}</TableCell>
|
||||||
<TableCell className="text-center">{user._count.comments}</TableCell>
|
<TableCell className="text-center">{user._count.comments}</TableCell>
|
||||||
<TableCell className="text-right text-muted-foreground text-sm">
|
<TableCell className="text-right text-sm">
|
||||||
{formatBytes(userStorage[user.id] || 0)}
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="font-medium text-foreground">{formatBytes(userStorage[user.id]?.total || 0)}</span>
|
||||||
|
{(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && (
|
||||||
|
<span className="text-xs text-muted-foreground mt-0.5 whitespace-nowrap space-x-1">
|
||||||
|
{userStorage[user.id]?.voice > 0 && <span>🎤 {formatBytes(userStorage[user.id]?.voice)}</span>}
|
||||||
|
{userStorage[user.id]?.voice > 0 && userStorage[user.id]?.image > 0 && <span>•</span>}
|
||||||
|
{userStorage[user.id]?.image > 0 && <span>🖼️ {formatBytes(userStorage[user.id]?.image)}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export async function GET() {
|
|||||||
totalVideos,
|
totalVideos,
|
||||||
totalComments,
|
totalComments,
|
||||||
totalVoiceComments,
|
totalVoiceComments,
|
||||||
|
totalImageComments,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.user.count(),
|
db.user.count(),
|
||||||
db.project.count(),
|
db.project.count(),
|
||||||
@@ -28,6 +29,13 @@ export async function GET() {
|
|||||||
not: null,
|
not: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
db.comment.count({
|
||||||
|
where: {
|
||||||
|
imageUrl: {
|
||||||
|
not: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -40,6 +48,7 @@ export async function GET() {
|
|||||||
totalVideos,
|
totalVideos,
|
||||||
totalComments,
|
totalComments,
|
||||||
totalVoiceComments,
|
totalVoiceComments,
|
||||||
|
totalImageComments,
|
||||||
totalStorageBytes,
|
totalStorageBytes,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -16,12 +16,46 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
const comment = await db.comment.findUnique({
|
const comment = await db.comment.findUnique({
|
||||||
where: { id: commentId },
|
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 } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
replies: {
|
replies: {
|
||||||
orderBy: { createdAt: 'asc' },
|
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 } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
version: {
|
version: {
|
||||||
@@ -155,7 +189,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (content !== undefined) updateData.content = content.trim();
|
if (content !== undefined && typeof content === 'string') updateData.content = content.trim();
|
||||||
if (tagId !== undefined) updateData.tagId = tagId;
|
if (tagId !== undefined) updateData.tagId = tagId;
|
||||||
if (isResolved !== undefined) {
|
if (isResolved !== undefined) {
|
||||||
updateData.isResolved = isResolved;
|
updateData.isResolved = isResolved;
|
||||||
@@ -201,7 +235,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
const comment = await db.comment.findUnique({
|
const comment = await db.comment.findUnique({
|
||||||
where: { id: commentId },
|
where: { id: commentId },
|
||||||
include: {
|
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');
|
return apiErrors.forbidden('You can only delete your own comments');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all voice URLs to delete from R2 (comment + its replies)
|
// Collect all media URLs to delete from R2 (comment + its replies)
|
||||||
const voiceUrls: string[] = [];
|
const mediaUrls: string[] = [];
|
||||||
if (comment.voiceUrl) voiceUrls.push(comment.voiceUrl);
|
if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl);
|
||||||
|
if (comment.imageUrl) mediaUrls.push(comment.imageUrl);
|
||||||
for (const reply of comment.replies) {
|
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 } });
|
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/';
|
const AUDIO_PREFIX = '/api/upload/audio/';
|
||||||
for (const url of voiceUrls) {
|
const IMAGE_PREFIX = '/api/upload/image/';
|
||||||
|
for (const url of mediaUrls) {
|
||||||
try {
|
try {
|
||||||
// Extract filename using string parsing (safe against ReDoS)
|
// Extract filename using string parsing (safe against ReDoS)
|
||||||
const idx = url.indexOf(AUDIO_PREFIX);
|
let key: string | null = null;
|
||||||
const filename = idx !== -1 ? url.slice(idx + AUDIO_PREFIX.length) : null;
|
if (url.includes(AUDIO_PREFIX)) {
|
||||||
if (filename) {
|
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(
|
await r2Client.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
Bucket: R2_BUCKET_NAME,
|
Bucket: R2_BUCKET_NAME,
|
||||||
Key: `voice/${filename}`,
|
Key: key,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { db } from '@/lib/db';
|
|||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { ProjectMemberRole, ProjectVisibility } from '@prisma/client';
|
import { ProjectMemberRole, ProjectVisibility } from '@prisma/client';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
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';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||||
@@ -52,11 +52,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
// Parse pagination params
|
// Parse pagination params
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
|
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({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
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
|
// 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 } });
|
await db.project.delete({ where: { id: projectId } });
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
|
|||||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
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';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
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
|
// Parse query params for pagination and options
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
|
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 includeReplies = searchParams.get('includeReplies') === 'true';
|
||||||
|
|
||||||
const video = await db.video.findFirst({
|
const video = await db.video.findFirst({
|
||||||
@@ -32,20 +32,54 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
orderBy: { timestamp: 'asc' },
|
orderBy: { timestamp: 'asc' },
|
||||||
skip: commentOffset,
|
skip: commentOffset,
|
||||||
take: commentLimit,
|
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 } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
...(includeReplies ? {
|
...(includeReplies ? {
|
||||||
replies: {
|
replies: {
|
||||||
orderBy: { createdAt: 'asc' },
|
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 } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} : {}),
|
} : {}),
|
||||||
},
|
},
|
||||||
where: { parentId: null }, // Only top-level comments
|
where: { parentId: null },
|
||||||
},
|
},
|
||||||
_count: { select: { comments: true } },
|
_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
|
// 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 } });
|
await db.video.delete({ where: { id: videoId } });
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export async function GET(
|
|||||||
|
|
||||||
// Convert stream to Uint8Array
|
// Convert stream to Uint8Array
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
// @ts-expect-error - body is an iterable
|
const asyncIterable = body as AsyncIterable<Uint8Array>;
|
||||||
for await (const chunk of body) {
|
for await (const chunk of asyncIterable) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
}
|
}
|
||||||
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
|
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,12 +71,44 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
...(includeResolved ? {} : { isResolved: false }),
|
...(includeResolved ? {} : { isResolved: false }),
|
||||||
},
|
},
|
||||||
orderBy: { timestamp: 'asc' },
|
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 } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
replies: {
|
replies: {
|
||||||
orderBy: { createdAt: 'asc' },
|
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 } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
tag: { select: { id: true, name: true, color: 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 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
|
// Validate required fields
|
||||||
if (timestamp === undefined || timestamp === null) {
|
if (timestamp === undefined || timestamp === null) {
|
||||||
return apiErrors.badRequest('Timestamp is required');
|
return apiErrors.badRequest('Timestamp is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content && !voiceUrl) {
|
const parsedTimestamp = parseFloat(timestamp);
|
||||||
return apiErrors.badRequest('Either content or voice recording is required');
|
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
|
// 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({
|
const comment = await db.comment.create({
|
||||||
data: {
|
data: {
|
||||||
content: content?.trim() || null,
|
content: content?.trim() || null,
|
||||||
timestamp: parseFloat(timestamp),
|
timestamp: parsedTimestamp,
|
||||||
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
|
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
|
||||||
parentId: parentId || null,
|
parentId: parentId || null,
|
||||||
voiceUrl: voiceUrl || null,
|
voiceUrl: voiceUrl || null,
|
||||||
voiceDuration: voiceDuration || null,
|
voiceDuration: voiceDuration || null,
|
||||||
|
imageUrl: imageUrl || null,
|
||||||
authorId: session?.user?.id || null,
|
authorId: session?.user?.id || null,
|
||||||
guestName: isGuest ? guestName : null,
|
guestName: isGuest ? guestName : null,
|
||||||
guestEmail: isGuest ? guestEmail : null,
|
guestEmail: isGuest ? guestEmail : null,
|
||||||
@@ -234,7 +279,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
videoTitle,
|
videoTitle,
|
||||||
replyAuthor: commentAuthorName,
|
replyAuthor: commentAuthorName,
|
||||||
replyText: content?.trim() || '(voice note)',
|
replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
|
||||||
parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone',
|
parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone',
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
url: `${baseUrl}/watch/${version.video.id}`,
|
url: `${baseUrl}/watch/${version.video.id}`,
|
||||||
@@ -245,7 +290,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
videoTitle,
|
videoTitle,
|
||||||
commentAuthor: commentAuthorName,
|
commentAuthor: commentAuthorName,
|
||||||
commentText: content?.trim() || '(voice note)',
|
commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
url: `${baseUrl}/watch/${version.video.id}`,
|
url: `${baseUrl}/watch/${version.video.id}`,
|
||||||
}).catch((err) => console.error('Notification failed:', err));
|
}).catch((err) => console.error('Notification failed:', err));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
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';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ workspaceId: string }> };
|
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
|
// 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 } });
|
await db.workspace.delete({ where: { id: workspaceId } });
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
User,
|
User,
|
||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Download,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -102,6 +104,7 @@ interface Comment {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
voiceUrl: string | null;
|
voiceUrl: string | null;
|
||||||
voiceDuration: number | null;
|
voiceDuration: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
isResolved: boolean;
|
isResolved: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
author: { id: string; name: string | null; image: string | null } | null;
|
author: { id: string; name: string | null; image: string | null } | null;
|
||||||
@@ -112,6 +115,7 @@ interface Comment {
|
|||||||
content: string | null;
|
content: string | null;
|
||||||
voiceUrl: string | null;
|
voiceUrl: string | null;
|
||||||
voiceDuration: number | null;
|
voiceDuration: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
author: { id: string; name: string | null; image: string | null } | null;
|
author: { id: string; name: string | null; image: string | null } | null;
|
||||||
guestName: string | null;
|
guestName: string | null;
|
||||||
@@ -185,6 +189,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const [recordingTime, setRecordingTime] = useState(0);
|
const [recordingTime, setRecordingTime] = useState(0);
|
||||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||||
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
|
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
|
||||||
|
const [imageBlob, setImageBlob] = useState<File | null>(null);
|
||||||
|
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||||
const [voiceProgress, setVoiceProgress] = useState(0);
|
const [voiceProgress, setVoiceProgress] = useState(0);
|
||||||
const [voiceCurrentTime, setVoiceCurrentTime] = 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 [replyRecordingTime, setReplyRecordingTime] = useState(0);
|
||||||
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
|
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
|
||||||
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
|
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
|
||||||
|
const [replyImageBlob, setReplyImageBlob] = useState<File | null>(null);
|
||||||
|
const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false);
|
||||||
|
const replyImageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const replyAudioChunksRef = useRef<Blob[]>([]);
|
const replyAudioChunksRef = useRef<Blob[]>([]);
|
||||||
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -229,6 +239,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
||||||
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
|
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
|
||||||
const isMutatingRef = useRef(false);
|
const isMutatingRef = useRef(false);
|
||||||
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
|
|
||||||
const [guestName, setGuestName] = useState('');
|
const [guestName, setGuestName] = useState('');
|
||||||
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
|
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
|
||||||
@@ -893,17 +904,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
}
|
}
|
||||||
}, [isDragging, currentTime, handleSeekToTimestamp]);
|
}, [isDragging, currentTime, handleSeekToTimestamp]);
|
||||||
|
|
||||||
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
|
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
|
||||||
if (!voiceData && !commentText.trim()) return;
|
if (!voiceData && !imageBlob && !commentText.trim()) return;
|
||||||
if (!activeVersion) return;
|
if (!activeVersion) return;
|
||||||
|
|
||||||
const tempId = `temp-${Date.now()}`;
|
const tempId = `temp-${Date.now()}`;
|
||||||
const optimisticComment: Comment = {
|
const optimisticComment: Comment = {
|
||||||
id: tempId,
|
id: tempId,
|
||||||
content: voiceData ? commentText.trim() || null : commentText,
|
content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
|
||||||
timestamp: selectedTimestamp ?? currentTime,
|
timestamp: selectedTimestamp ?? currentTime,
|
||||||
voiceUrl: voiceData?.url ?? null,
|
voiceUrl: voiceData?.url ?? null,
|
||||||
voiceDuration: voiceData?.duration ?? null,
|
voiceDuration: voiceData?.duration ?? null,
|
||||||
|
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||||
@@ -928,18 +940,37 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
setSelectedTimestamp(null);
|
setSelectedTimestamp(null);
|
||||||
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
|
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
|
||||||
setAudioBlob(null);
|
setAudioBlob(null);
|
||||||
|
setImageBlob(null);
|
||||||
|
|
||||||
setIsSubmittingComment(true);
|
setIsSubmittingComment(true);
|
||||||
isMutatingRef.current = true;
|
isMutatingRef.current = true;
|
||||||
|
|
||||||
try {
|
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`, {
|
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content: voiceData ? commentText.trim() || null : commentText,
|
content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
|
||||||
timestamp: selectedTimestamp ?? currentTime,
|
timestamp: selectedTimestamp ?? currentTime,
|
||||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||||
|
...(imageData && { imageUrl: imageData.url }),
|
||||||
...(isGuest && guestName && { guestName }),
|
...(isGuest && guestName && { guestName }),
|
||||||
...(selectedTagId && { tagId: selectedTagId }),
|
...(selectedTagId && { tagId: selectedTagId }),
|
||||||
}),
|
}),
|
||||||
@@ -988,9 +1019,55 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
toast.error('Failed to add comment');
|
toast.error('Failed to add comment');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmittingComment(false);
|
setIsSubmittingComment(false);
|
||||||
|
setIsUploadingImage(false);
|
||||||
isMutatingRef.current = 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<HTMLInputElement>, 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<HTMLTextAreaElement>, 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 () => {
|
const startRecording = useCallback(async () => {
|
||||||
try {
|
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(
|
const handleResolveComment = useCallback(
|
||||||
async (commentId: string, currentlyResolved: boolean) => {
|
async (commentId: string, currentlyResolved: boolean) => {
|
||||||
isMutatingRef.current = true;
|
isMutatingRef.current = true;
|
||||||
@@ -1245,17 +1362,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
[activeVersionId]
|
[activeVersionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
|
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
|
||||||
if (!voiceData && !replyText.trim()) return;
|
if (!voiceData && !replyImageBlob && !replyText.trim()) return;
|
||||||
if (!activeVersion) return;
|
if (!activeVersion) return;
|
||||||
|
|
||||||
const tempId = `temp-reply-${Date.now()}`;
|
const tempId = `temp-reply-${Date.now()}`;
|
||||||
const parentComment = comments.find((c) => c.id === parentId);
|
const parentComment = comments.find((c) => c.id === parentId);
|
||||||
const optimisticReply = {
|
const optimisticReply = {
|
||||||
id: tempId,
|
id: tempId,
|
||||||
content: voiceData ? replyText.trim() || null : replyText,
|
content: (voiceData || replyImageBlob) ? replyText.trim() || null : replyText,
|
||||||
voiceUrl: voiceData?.url ?? null,
|
voiceUrl: voiceData?.url ?? null,
|
||||||
voiceDuration: voiceData?.duration ?? null,
|
voiceDuration: voiceData?.duration ?? null,
|
||||||
|
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||||
guestName: isGuest ? guestName : null,
|
guestName: isGuest ? guestName : null,
|
||||||
@@ -1285,19 +1403,38 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
setReplyingTo(null);
|
setReplyingTo(null);
|
||||||
setReplyAudioBlob(null);
|
setReplyAudioBlob(null);
|
||||||
setReplyRecordingTime(0);
|
setReplyRecordingTime(0);
|
||||||
|
setReplyImageBlob(null);
|
||||||
|
|
||||||
setIsSubmittingReply(true);
|
setIsSubmittingReply(true);
|
||||||
isMutatingRef.current = true;
|
isMutatingRef.current = true;
|
||||||
|
|
||||||
try {
|
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`, {
|
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content: voiceData ? replyText.trim() || null : replyText,
|
content: (voiceData || submittedImageData) ? replyText.trim() || null : replyText,
|
||||||
timestamp: parentComment?.timestamp ?? currentTime,
|
timestamp: parentComment?.timestamp ?? currentTime,
|
||||||
parentId,
|
parentId,
|
||||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||||
|
...(submittedImageData && { imageUrl: submittedImageData.url }),
|
||||||
...(isGuest && guestName && { guestName }),
|
...(isGuest && guestName && { guestName }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -1366,9 +1503,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
toast.error('Failed to add reply');
|
toast.error('Failed to add reply');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmittingReply(false);
|
setIsSubmittingReply(false);
|
||||||
|
setIsUploadingReplyImage(false);
|
||||||
isMutatingRef.current = false;
|
isMutatingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]);
|
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, replyImageBlob]);
|
||||||
|
|
||||||
const startReplyRecording = useCallback(async () => {
|
const startReplyRecording = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1437,6 +1575,44 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
}
|
}
|
||||||
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]);
|
}, [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) => {
|
const handleEditComment = useCallback(async (commentId: string) => {
|
||||||
if (!editText.trim()) return;
|
if (!editText.trim()) return;
|
||||||
setIsSubmittingEdit(true);
|
setIsSubmittingEdit(true);
|
||||||
@@ -1502,7 +1678,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
.filter((c) => c.id !== commentId)
|
.filter((c) => c.id !== commentId)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
replies: c.replies.filter((r) => r.id !== commentId),
|
replies: c.replies?.filter((r) => r.id !== commentId) || [],
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: v
|
: v
|
||||||
@@ -2381,7 +2557,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>
|
<div className="mb-2">
|
||||||
|
{comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>}
|
||||||
|
{comment.imageUrl && (
|
||||||
|
<div
|
||||||
|
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
onClick={() => setPreviewImage(comment.imageUrl)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={comment.imageUrl} alt="Attachment" className="max-h-60 w-auto object-contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{comment.voiceUrl && (
|
{comment.voiceUrl && (
|
||||||
@@ -2524,7 +2711,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>
|
<div className="mb-1">
|
||||||
|
{reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>}
|
||||||
|
{reply.imageUrl && (
|
||||||
|
<div
|
||||||
|
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
onClick={() => setPreviewImage(reply.imageUrl)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={reply.imageUrl} alt="Attachment" className="max-h-40 w-auto object-contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{reply.voiceUrl && (
|
{reply.voiceUrl && (
|
||||||
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
|
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
|
||||||
@@ -2620,6 +2818,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{replyImageBlob && (
|
||||||
|
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
|
||||||
|
setReplyImageBlob(null);
|
||||||
|
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
value={replyText}
|
value={replyText}
|
||||||
onChange={(e) => setReplyText(e.target.value)}
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
@@ -2627,20 +2841,34 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
rows={1}
|
rows={1}
|
||||||
className="resize-none text-sm"
|
className="resize-none text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 mt-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => submitVoiceReply(comment.id)}
|
onClick={() => submitReplyWithMedia(comment.id)}
|
||||||
disabled={isUploadingReplyAudio}
|
disabled={isUploadingReplyAudio || isUploadingReplyImage}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
{isUploadingReplyAudio ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Voice Reply'}
|
{isUploadingReplyAudio || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Reply'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</Button>
|
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{replyImageBlob && (
|
||||||
|
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
|
||||||
|
setReplyImageBlob(null);
|
||||||
|
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={replyText}
|
value={replyText}
|
||||||
@@ -2658,6 +2886,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
setReplyText('');
|
setReplyText('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onPaste={(e) => handlePaste(e, true)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -2668,15 +2897,31 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
>
|
>
|
||||||
<Mic className="h-3 w-3" />
|
<Mic className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => replyImageInputRef.current?.click()}
|
||||||
|
title="Attach Image"
|
||||||
|
className="h-8 w-8 shrink-0 self-end"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
ref={replyImageInputRef}
|
||||||
|
onChange={(e) => handleImageSelect(e, true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleReplyComment(comment.id)}
|
onClick={() => handleReplyComment(comment.id)}
|
||||||
disabled={!replyText.trim() || isSubmittingReply}
|
disabled={(!replyText.trim() && !replyImageBlob) || isSubmittingReply || isUploadingReplyImage}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
{isSubmittingReply ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
|
{isSubmittingReply || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -2789,6 +3034,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{imageBlob && (
|
||||||
|
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<Button size="icon" variant="destructive" onClick={() => {
|
||||||
|
setImageBlob(null);
|
||||||
|
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Add a note to your voice comment (optional)..."
|
placeholder="Add a note to your voice comment (optional)..."
|
||||||
value={commentText}
|
value={commentText}
|
||||||
@@ -2798,14 +3059,14 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={submitVoiceComment}
|
onClick={submitCommentWithMedia}
|
||||||
disabled={isUploadingAudio}
|
disabled={isUploadingAudio || isUploadingImage}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{isUploadingAudio ? (
|
{isUploadingAudio || isUploadingImage ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
Uploading...
|
Uploading Media...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -2817,6 +3078,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{imageBlob && (
|
||||||
|
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<Button size="icon" variant="destructive" onClick={() => {
|
||||||
|
setImageBlob(null);
|
||||||
|
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Add a comment..."
|
placeholder="Add a comment..."
|
||||||
@@ -2829,14 +3104,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
handleAddComment();
|
handleAddComment();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onPaste={(e) => handlePaste(e, false)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleAddComment()}
|
onClick={() => handleAddComment()}
|
||||||
disabled={!commentText.trim() || isSubmittingComment}
|
disabled={(!commentText.trim() && !imageBlob) || isSubmittingComment || isUploadingImage}
|
||||||
>
|
>
|
||||||
{isSubmittingComment ? (
|
{isSubmittingComment || isUploadingImage ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
@@ -2850,6 +3126,21 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => imageInputRef.current?.click()}
|
||||||
|
title="Attach Image"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
ref={imageInputRef}
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
/>
|
||||||
{availableTags.length > 0 && (
|
{availableTags.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -2897,6 +3188,66 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!previewImage} onOpenChange={(open) => !open && setPreviewImage(null)}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex flex-col items-center justify-center rounded-none"
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">Image Preview</DialogTitle>
|
||||||
|
<div className="absolute top-4 right-4 flex gap-3 z-50">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
const response = await fetch(previewImage!);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = previewImage!.split('/').pop() || 'attachment.png';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download image:', error);
|
||||||
|
toast.error('Failed to download image');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreviewImage(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative w-full h-full flex items-center justify-center p-4 cursor-zoom-out"
|
||||||
|
onClick={() => setPreviewImage(null)}
|
||||||
|
>
|
||||||
|
{previewImage && (
|
||||||
|
<img
|
||||||
|
src={previewImage}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-w-[95vw] max-h-[90vh] object-contain rounded-md select-none cursor-default"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-13
@@ -34,10 +34,10 @@ export const getCachedTotalStorage = unstable_cache(
|
|||||||
{ revalidate: 600 }
|
{ revalidate: 600 }
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getCachedUserVoiceStorage = unstable_cache(
|
export const getCachedUserMediaStorage = unstable_cache(
|
||||||
async () => {
|
async () => {
|
||||||
// Return a plain object so it maps cleanly out of unstable_cache across requests
|
// Return a plain object so it maps cleanly out of unstable_cache across requests
|
||||||
const userStorage: Record<string, number> = {};
|
const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
|
||||||
try {
|
try {
|
||||||
const fileSizes = new Map<string, number>();
|
const fileSizes = new Map<string, number>();
|
||||||
let isTruncated = true;
|
let isTruncated = true;
|
||||||
@@ -57,25 +57,41 @@ export const getCachedUserVoiceStorage = unstable_cache(
|
|||||||
continuationToken = data.NextContinuationToken;
|
continuationToken = data.NextContinuationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const voiceComments = await db.comment.findMany({
|
const mediaComments = await db.comment.findMany({
|
||||||
where: { voiceUrl: { not: null }, authorId: { not: null } },
|
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], authorId: { not: null } },
|
||||||
select: { authorId: true, voiceUrl: true }
|
select: { authorId: true, voiceUrl: true, imageUrl: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const comment of voiceComments) {
|
for (const comment of mediaComments) {
|
||||||
if (!comment.authorId || !comment.voiceUrl) continue;
|
if (!comment.authorId) continue;
|
||||||
const keyParts = comment.voiceUrl.split('/');
|
|
||||||
const filename = keyParts[keyParts.length - 1];
|
|
||||||
const r2Key = `voice/${filename}`;
|
|
||||||
const size = fileSizes.get(r2Key) || 0;
|
|
||||||
|
|
||||||
userStorage[comment.authorId] = (userStorage[comment.authorId] || 0) + size;
|
if (!userStorage[comment.authorId]) {
|
||||||
|
userStorage[comment.authorId] = { total: 0, voice: 0, image: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.voiceUrl) {
|
||||||
|
const keyParts = comment.voiceUrl.split('/');
|
||||||
|
const filename = keyParts[keyParts.length - 1];
|
||||||
|
const r2Key = `voice/${filename}`;
|
||||||
|
const size = fileSizes.get(r2Key) || 0;
|
||||||
|
userStorage[comment.authorId].voice += size;
|
||||||
|
userStorage[comment.authorId].total += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.imageUrl) {
|
||||||
|
const keyParts = comment.imageUrl.split('/');
|
||||||
|
const filename = keyParts[keyParts.length - 1];
|
||||||
|
const r2Key = `images/${filename}`;
|
||||||
|
const size = fileSizes.get(r2Key) || 0;
|
||||||
|
userStorage[comment.authorId].image += size;
|
||||||
|
userStorage[comment.authorId].total += size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse user storage:', err);
|
console.error('Failed to parse user storage:', err);
|
||||||
}
|
}
|
||||||
return userStorage;
|
return userStorage;
|
||||||
},
|
},
|
||||||
['admin-user-voice-storage'],
|
['admin-user-media-storage'],
|
||||||
{ revalidate: 600 }
|
{ revalidate: 600 }
|
||||||
);
|
);
|
||||||
|
|||||||
+59
-38
@@ -2,103 +2,124 @@ import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
|||||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
/** The path prefix for images served by the upload API. */
|
||||||
|
const IMAGE_PATH_PREFIX = '/api/upload/image/';
|
||||||
/** The path prefix for audio URLs served by the upload API. */
|
/** The path prefix for audio URLs served by the upload API. */
|
||||||
const AUDIO_PATH_PREFIX = '/api/upload/audio/';
|
const AUDIO_PATH_PREFIX = '/api/upload/audio/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the R2 object key from a voice URL like /api/upload/audio/filename.webm.
|
* Extract the R2 object key from a media URL.
|
||||||
* Uses string parsing instead of regex to avoid ReDoS risk on untrusted input.
|
* Uses string parsing instead of regex to avoid ReDoS risk on untrusted input.
|
||||||
*/
|
*/
|
||||||
function voiceUrlToKey(url: string): string | null {
|
function mediaUrlToKey(url: string): string | null {
|
||||||
const idx = url.indexOf(AUDIO_PATH_PREFIX);
|
if (url.includes(AUDIO_PATH_PREFIX)) {
|
||||||
if (idx === -1) return null;
|
const filename = url.slice(url.indexOf(AUDIO_PATH_PREFIX) + AUDIO_PATH_PREFIX.length);
|
||||||
const filename = url.slice(idx + AUDIO_PATH_PREFIX.length);
|
return filename ? `voice/${filename}` : null;
|
||||||
return filename ? `voice/${filename}` : null;
|
} else if (url.includes(IMAGE_PATH_PREFIX)) {
|
||||||
|
const filename = url.slice(url.indexOf(IMAGE_PATH_PREFIX) + IMAGE_PATH_PREFIX.length);
|
||||||
|
return filename ? `images/${filename}` : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a list of voice files from R2 (best-effort, logs failures).
|
* Delete a list of media files from R2 (best-effort, logs failures).
|
||||||
*/
|
*/
|
||||||
async function deleteVoiceFiles(voiceUrls: string[]) {
|
async function deleteMediaFiles(mediaUrls: string[]) {
|
||||||
for (const url of voiceUrls) {
|
for (const url of mediaUrls) {
|
||||||
try {
|
try {
|
||||||
const key = voiceUrlToKey(url);
|
const key = mediaUrlToKey(url);
|
||||||
if (key) {
|
if (key) {
|
||||||
await r2Client.send(
|
await r2Client.send(
|
||||||
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
|
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete audio from R2:', err);
|
console.error('Failed to delete media from R2:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all voice URLs from comments under a given video (all versions).
|
* Collect all media URLs from comments under a given video (all versions).
|
||||||
*/
|
*/
|
||||||
export async function collectVideoVoiceUrls(videoId: string): Promise<string[]> {
|
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
|
||||||
const comments = await db.comment.findMany({
|
const comments = await db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
voiceUrl: { not: null },
|
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||||
version: { videoParentId: videoId },
|
version: { videoParentId: videoId },
|
||||||
},
|
},
|
||||||
select: { voiceUrl: true },
|
select: { voiceUrl: true, imageUrl: true },
|
||||||
});
|
});
|
||||||
return comments.map((c: { voiceUrl: string | null }) => c.voiceUrl).filter(Boolean) as string[];
|
const urls: string[] = [];
|
||||||
|
comments.forEach(c => {
|
||||||
|
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||||
|
if (c.imageUrl) urls.push(c.imageUrl);
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all voice URLs from comments under all videos in a project.
|
* Collect all media URLs from comments under all videos in a project.
|
||||||
*/
|
*/
|
||||||
export async function collectProjectVoiceUrls(projectId: string): Promise<string[]> {
|
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
|
||||||
const comments = await db.comment.findMany({
|
const comments = await db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
voiceUrl: { not: null },
|
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||||
version: { video: { projectId } },
|
version: { video: { projectId } },
|
||||||
},
|
},
|
||||||
select: { voiceUrl: true },
|
select: { voiceUrl: true, imageUrl: true },
|
||||||
});
|
});
|
||||||
return comments.map((c: { voiceUrl: string | null }) => c.voiceUrl).filter(Boolean) as string[];
|
const urls: string[] = [];
|
||||||
|
comments.forEach(c => {
|
||||||
|
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||||
|
if (c.imageUrl) urls.push(c.imageUrl);
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all voice URLs from comments under all projects in a workspace.
|
* Collect all media URLs from comments under all projects in a workspace.
|
||||||
*/
|
*/
|
||||||
export async function collectWorkspaceVoiceUrls(workspaceId: string): Promise<string[]> {
|
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
|
||||||
const comments = await db.comment.findMany({
|
const comments = await db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
voiceUrl: { not: null },
|
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||||
version: { video: { project: { workspaceId } } },
|
version: { video: { project: { workspaceId } } },
|
||||||
},
|
},
|
||||||
select: { voiceUrl: true },
|
select: { voiceUrl: true, imageUrl: true },
|
||||||
});
|
});
|
||||||
return comments.map((c: { voiceUrl: string | null }) => c.voiceUrl).filter(Boolean) as string[];
|
const urls: string[] = [];
|
||||||
|
comments.forEach(c => {
|
||||||
|
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||||
|
if (c.imageUrl) urls.push(c.imageUrl);
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all voice files for a video from R2.
|
* Delete all media files for a video from R2.
|
||||||
* Call BEFORE deleting the video from the database (cascade would remove comment rows).
|
* Call BEFORE deleting the video from the database (cascade would remove comment rows).
|
||||||
*/
|
*/
|
||||||
export async function cleanupVideoVoiceFiles(videoId: string) {
|
export async function cleanupVideoMediaFiles(videoId: string) {
|
||||||
const urls = await collectVideoVoiceUrls(videoId);
|
const urls = await collectVideoMediaUrls(videoId);
|
||||||
await deleteVoiceFiles(urls);
|
await deleteMediaFiles(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all voice files for a project from R2.
|
* Delete all media files for a project from R2.
|
||||||
* Call BEFORE deleting the project from the database.
|
* Call BEFORE deleting the project from the database.
|
||||||
*/
|
*/
|
||||||
export async function cleanupProjectVoiceFiles(projectId: string) {
|
export async function cleanupProjectMediaFiles(projectId: string) {
|
||||||
const urls = await collectProjectVoiceUrls(projectId);
|
const urls = await collectProjectMediaUrls(projectId);
|
||||||
await deleteVoiceFiles(urls);
|
await deleteMediaFiles(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all voice files for a workspace from R2.
|
* Delete all media files for a workspace from R2.
|
||||||
* Call BEFORE deleting the workspace from the database.
|
* Call BEFORE deleting the workspace from the database.
|
||||||
*/
|
*/
|
||||||
export async function cleanupWorkspaceVoiceFiles(workspaceId: string) {
|
export async function cleanupWorkspaceMediaFiles(workspaceId: string) {
|
||||||
const urls = await collectWorkspaceVoiceUrls(workspaceId);
|
const urls = await collectWorkspaceMediaUrls(workspaceId);
|
||||||
await deleteVoiceFiles(urls);
|
await deleteMediaFiles(urls);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,9 @@ model Comment {
|
|||||||
voiceUrl String? // URL to voice recording file
|
voiceUrl String? // URL to voice recording file
|
||||||
voiceDuration Float? // Duration of voice recording in seconds
|
voiceDuration Float? // Duration of voice recording in seconds
|
||||||
|
|
||||||
|
// Image attachment (optional)
|
||||||
|
imageUrl String? // URL to uploaded image file
|
||||||
|
|
||||||
// Threading
|
// Threading
|
||||||
parentId String?
|
parentId String?
|
||||||
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
Reference in New Issue
Block a user