Merge pull request #63 from yusufipk/feat/multi-image-comment-attachments

feat(comments): carry a batch of screenshots on one comment
This commit is contained in:
Yusuf İpek
2026-08-20 11:43:38 +03:00
committed by GitHub
32 changed files with 1587 additions and 488 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ export default async function AdminDashboardPage() {
where: { voiceUrl: { not: null } }, where: { voiceUrl: { not: null } },
}), }),
db.comment.count({ db.comment.count({
where: { imageUrl: { not: null } }, where: { images: { some: {} } },
}), }),
]); ]);
+2 -2
View File
@@ -105,8 +105,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] = const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] =
await Promise.all([ await Promise.all([
db.comment.findFirst({ db.commentImage.findFirst({
where: { imageUrl: url }, where: { url },
select: { id: true }, select: { id: true },
}), }),
userFeedbackDelegate.findFirst({ userFeedbackDelegate.findFirst({
+144 -7
View File
@@ -10,6 +10,14 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
import { getGuestIdentityFromRequest } from '@/lib/guest-identity'; import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
import { runWithConcurrency } from '@/lib/async-pool'; import { runWithConcurrency } from '@/lib/async-pool';
import { validateAnnotationStrokes } from '@/lib/validation'; import { validateAnnotationStrokes } from '@/lib/validation';
import { parseCommentImageUrls } from '@/lib/comment-images';
import { isFreshAttachment } from '@/lib/upload-freshness';
import { extractImageFileNameFromProxyUrl, sanitizeAssetDisplayName } from '@/lib/video-assets';
import {
reserveStorageQuota,
releaseStorageReservation,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
const CLEANUP_DELETE_CONCURRENCY = 5; const CLEANUP_DELETE_CONCURRENCY = 5;
@@ -36,6 +44,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
parentId: true, parentId: true,
authorId: true, authorId: true,
tagId: true, tagId: true,
@@ -57,6 +66,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
parentId: true, parentId: true,
authorId: true, authorId: true,
tagId: true, tagId: true,
@@ -103,6 +113,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// PATCH /api/comments/[commentId] // PATCH /api/comments/[commentId]
export async function PATCH(request: NextRequest, { params }: RouteParams) { export async function PATCH(request: NextRequest, { params }: RouteParams) {
// Carried out of the try so the catch below can scope the release to the
// account the hold was opened against.
let attachmentReservationId: string | null = null;
let attachmentBilledUserId: string | null = null;
try { try {
const limited = await rateLimit(request, 'mutate'); const limited = await rateLimit(request, 'mutate');
if (limited) return limited; if (limited) return limited;
@@ -115,11 +129,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const comment = await db.comment.findUnique({ const comment = await db.comment.findUnique({
where: { id: commentId }, where: { id: commentId },
include: { include: {
images: { select: { url: true }, orderBy: { position: 'asc' } },
version: { version: {
include: { include: {
video: { video: {
include: { include: {
project: true, project: { include: { workspace: { select: { ownerId: true } } } },
}, },
}, },
}, },
@@ -169,14 +184,69 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
} }
} }
// Only author can edit content or tag // `imageUrls` (or the legacy `imageUrl`) is the full list the comment should
// end up with, so anything the caller left out is detached.
const wantsImageUpdate = body.imageUrls !== undefined || body.imageUrl !== undefined;
// Only author can edit content, tag or attachments
if ( if (
(content !== undefined || tagId !== undefined || annotationData !== undefined) && (content !== undefined ||
tagId !== undefined ||
annotationData !== undefined ||
wantsImageUpdate) &&
!canEditOwnContent !canEditOwnContent
) { ) {
return apiErrors.forbidden('Only the author can edit comment content'); return apiErrors.forbidden('Only the author can edit comment content');
} }
let desiredImageUrls: string[] = [];
let removedImageUrls: string[] = [];
let addedImages: { url: string; sizeBytes: bigint }[] = [];
if (wantsImageUpdate) {
const parsedImageUrls = parseCommentImageUrls(body);
if ('error' in parsedImageUrls) {
return apiErrors.badRequest(parsedImageUrls.error);
}
desiredImageUrls = parsedImageUrls.urls;
const existingUrls = comment.images.map((image) => image.url);
const addedUrls = desiredImageUrls.filter((url) => !existingUrls.includes(url));
removedImageUrls = existingUrls.filter((url) => !desiredImageUrls.includes(url));
if (addedUrls.length > 0) {
// A file that already hangs off another comment would trip the unique
// index mid-transaction, so refuse it here and answer with a 400.
const alreadyClaimed = await db.commentImage.findFirst({
where: { url: { in: addedUrls } },
select: { id: true },
});
if (alreadyClaimed) {
return apiErrors.badRequest('Image is already attached to another comment');
}
const checks = await Promise.all(
addedUrls.map(async (url) => ({ url, ...(await isFreshAttachment(url, 'image')) }))
);
if (checks.some((check) => !check.isFresh)) {
return apiErrors.badRequest('Image upload expired. Please upload again.');
}
addedImages = checks;
}
const addedBytes = addedImages.reduce((total, image) => total + image.sizeBytes, BigInt(0));
if (addedBytes > BigInt(0)) {
const reserveResult = await reserveStorageQuota(
project.workspace.ownerId,
addedBytes,
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
);
if ('error' in reserveResult) return reserveResult.error;
attachmentReservationId = reserveResult.reservationId;
attachmentBilledUserId = project.workspace.ownerId;
}
}
// Owner, author, members, or workspace members can resolve/unresolve // Owner, author, members, or workspace members can resolve/unresolve
if (isResolved !== undefined && !canResolveComment) { if (isResolved !== undefined && !canResolveComment) {
return apiErrors.forbidden('Only admins can resolve comments'); return apiErrors.forbidden('Only admins can resolve comments');
@@ -214,21 +284,80 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
updateData.isResolved = isResolved; updateData.isResolved = isResolved;
updateData.resolvedAt = isResolved ? new Date() : null; updateData.resolvedAt = isResolved ? new Date() : null;
} }
if (wantsImageUpdate) {
// The legacy column keeps pointing at the first image.
updateData.imageUrl = desiredImageUrls[0] ?? null;
}
const updatedComment = await db.comment.update({ const updatedComment = await db.$transaction(async (tx) => {
// Consume the hold inside the transaction so quota is never double-counted.
if (attachmentReservationId) {
await tx.uploadReservation.deleteMany({
where: {
id: attachmentReservationId,
billedUserId: project.workspace.ownerId,
purpose: UPLOAD_RESERVATION_PURPOSES.ATTACHMENT,
},
});
}
if (wantsImageUpdate) {
if (removedImageUrls.length > 0) {
// Only the link is dropped. The file stays in R2 and in the assets pane,
// which is where a detached upload is deleted from and where its storage
// is already accounted for.
await tx.commentImage.deleteMany({
where: { commentId, url: { in: removedImageUrls } },
});
}
for (const [index, url] of desiredImageUrls.entries()) {
const added = addedImages.find((image) => image.url === url);
if (!added) {
await tx.commentImage.update({ where: { url }, data: { position: index } });
continue;
}
await tx.commentImage.create({ data: { commentId, url, position: index } });
const fileName = extractImageFileNameFromProxyUrl(url);
await tx.videoAsset.create({
data: {
videoId: comment.version.video.id,
kind: 'IMAGE',
provider: 'R2_IMAGE',
displayName: sanitizeAssetDisplayName(null, fileName || 'Comment Image'),
sourceUrl: url,
thumbnailUrl: url,
sizeBytes: added.sizeBytes,
uploadedByUserId: userId,
uploadedByGuestIdentityId: userId ? null : guestIdentityId,
uploadedByGuestName: userId
? null
: sanitizeAssetDisplayName(comment.guestName, 'Guest').slice(0, 80),
billedUserId: project.workspace.ownerId,
},
});
}
}
return tx.comment.update({
where: { id: commentId }, where: { id: commentId },
data: updateData, data: updateData,
include: { include: {
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 } },
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
replies: { replies: {
include: { include: {
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 } },
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
}, },
}, },
}, },
}); });
});
const updatedCommentData = Object.fromEntries( const updatedCommentData = Object.fromEntries(
Object.entries(updatedComment).filter(([key]) => key !== 'guestIdentityId') Object.entries(updatedComment).filter(([key]) => key !== 'guestIdentityId')
@@ -256,6 +385,11 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
}); });
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
await releaseStorageReservation(
attachmentReservationId,
attachmentBilledUserId,
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
);
logError('Error updating comment:', error); logError('Error updating comment:', error);
return apiErrors.internalError('Failed to update comment'); return apiErrors.internalError('Failed to update comment');
} }
@@ -282,7 +416,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}, },
}, },
}, },
replies: { select: { voiceUrl: true, imageUrl: true } }, images: { select: { url: true } },
replies: {
select: { voiceUrl: true, images: { select: { url: true } } },
},
}, },
}); });
@@ -339,10 +476,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
// Collect all media URLs to delete from R2 (comment + its replies) // Collect all media URLs to delete from R2 (comment + its replies)
const mediaUrls: string[] = []; const mediaUrls: string[] = [];
if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl); if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl);
if (comment.imageUrl) mediaUrls.push(comment.imageUrl); for (const image of comment.images) mediaUrls.push(image.url);
for (const reply of comment.replies) { for (const reply of comment.replies) {
if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl); if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl);
if (reply.imageUrl) mediaUrls.push(reply.imageUrl); for (const image of reply.images) mediaUrls.push(image.url);
} }
await db.comment.delete({ where: { id: commentId } }); await db.comment.delete({ where: { id: commentId } });
@@ -50,6 +50,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
annotationData: true, annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
@@ -75,6 +76,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
images: {
select: { id: true, url: true },
orderBy: { position: 'asc' },
},
annotationData: true, annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
+35 -49
View File
@@ -6,8 +6,6 @@ import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { validateShareLinkAccess } from '@/lib/share-links'; import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session'; import { getShareSessionFromRequest } from '@/lib/share-session';
import { HeadObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { import {
ensureGuestIdentityFromRequest, ensureGuestIdentityFromRequest,
getGuestIdentityFromRequest, getGuestIdentityFromRequest,
@@ -20,6 +18,8 @@ import {
sanitizeAssetDisplayName, sanitizeAssetDisplayName,
} from '@/lib/video-assets'; } from '@/lib/video-assets';
import { validateAnnotationStrokes } from '@/lib/validation'; import { validateAnnotationStrokes } from '@/lib/validation';
import { parseCommentImageUrls } from '@/lib/comment-images';
import { isFreshAttachment } from '@/lib/upload-freshness';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { import {
reserveStorageQuota, reserveStorageQuota,
@@ -29,35 +29,8 @@ import {
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation'; import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
type RouteParams = { params: Promise<{ versionId: string }> }; type RouteParams = { params: Promise<{ versionId: string }> };
const SAFE_IMAGE_PATH =
/^\/api\/upload\/image\/[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 SAFE_AUDIO_PATH = const SAFE_AUDIO_PATH =
/^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; /^\/api\/upload\/audio\/[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 UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise<AttachmentCheck> {
const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/';
if (!url.startsWith(prefix)) return { isFresh: false, sizeBytes: BigInt(0) };
const filename = url.slice(prefix.length);
const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`;
try {
const head = await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
} catch {
return { isFresh: false, sizeBytes: BigInt(0) };
}
}
function normalizeEtag(value: string): string { function normalizeEtag(value: string): string {
return value.trim().replace(/^W\//, ''); return value.trim().replace(/^W\//, '');
@@ -153,6 +126,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
annotationData: true, annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
@@ -175,6 +149,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
annotationData: true, annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
@@ -275,10 +250,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
guestName, guestName,
guestEmail, guestEmail,
tagId, tagId,
imageUrl,
annotationData, annotationData,
} = body; } = body;
// A comment carries a list of images now; `imageUrl` is still accepted as a
// one-element list so an older client keeps working.
const imageUrlsResult = parseCommentImageUrls(body);
if ('error' in imageUrlsResult) {
return apiErrors.badRequest(imageUrlsResult.error);
}
const attachedImageUrls = imageUrlsResult.urls;
const primaryImageUrl = attachedImageUrls[0] ?? null;
// 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');
@@ -324,7 +307,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
} }
if (!content && !voiceUrl && !imageUrl && !annotationData) { if (!content && !voiceUrl && attachedImageUrls.length === 0 && !annotationData) {
return apiErrors.badRequest( return apiErrors.badRequest(
'Either content, a voice recording, an image attachment, or an annotation is required' 'Either content, a voice recording, an image attachment, or an annotation is required'
); );
@@ -402,17 +385,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
voiceSizeBytes = voiceCheck.sizeBytes; voiceSizeBytes = voiceCheck.sizeBytes;
} }
if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) { // The uploads happened in parallel, so check them the same way rather than
return apiErrors.badRequest('Image URL must reference an uploaded image file'); // paying one R2 round trip per screenshot.
} const imageChecks = await Promise.all(
let imageSizeBytes = BigInt(0); attachedImageUrls.map(async (url) => ({ url, ...(await isFreshAttachment(url, 'image')) }))
if (imageUrl) { );
const imageCheck = await isFreshAttachment(imageUrl, 'image'); if (imageChecks.some((check) => !check.isFresh)) {
if (!imageCheck.isFresh) {
return apiErrors.badRequest('Image upload expired. Please upload again.'); return apiErrors.badRequest('Image upload expired. Please upload again.');
} }
imageSizeBytes = imageCheck.sizeBytes; const imageSizeBytes = imageChecks.reduce((total, check) => total + check.sizeBytes, BigInt(0));
}
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
@@ -451,7 +432,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
parentId: parentId || null, parentId: parentId || null,
voiceUrl: voiceUrl || null, voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null, voiceDuration: voiceDuration || null,
imageUrl: imageUrl || null, imageUrl: primaryImageUrl,
images: {
create: attachedImageUrls.map((url, index) => ({ url, position: index })),
},
annotationData: serializedAnnotationData, annotationData: serializedAnnotationData,
authorId: session?.user?.id || null, authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null, guestName: isGuest ? guestName : null,
@@ -463,18 +447,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
include: { include: {
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 } },
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
replies: { replies: {
include: { include: {
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 } },
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
}, },
}, },
}, },
}); });
// If an image was attached to the comment, also add it to the assets pane // Every attached image also shows up in the assets pane
if (imageUrl) { for (const check of imageChecks) {
const fileName = extractImageFileNameFromProxyUrl(imageUrl); const fileName = extractImageFileNameFromProxyUrl(check.url);
const displayName = sanitizeAssetDisplayName(null, fileName || 'Comment Image'); const displayName = sanitizeAssetDisplayName(null, fileName || 'Comment Image');
const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80); const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80);
@@ -484,9 +470,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
kind: 'IMAGE', kind: 'IMAGE',
provider: 'R2_IMAGE', provider: 'R2_IMAGE',
displayName, displayName,
sourceUrl: imageUrl, sourceUrl: check.url,
thumbnailUrl: imageUrl, thumbnailUrl: check.url,
sizeBytes: imageSizeBytes, sizeBytes: check.sizeBytes,
uploadedByUserId: session?.user?.id || null, uploadedByUserId: session?.user?.id || null,
uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null, uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
uploadedByGuestName: isGuest ? safeGuestName : null, uploadedByGuestName: isGuest ? safeGuestName : null,
@@ -543,7 +529,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
projectName: project.name, projectName: project.name,
videoTitle, videoTitle,
replyAuthor: commentAuthorName, replyAuthor: commentAuthorName,
replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'), replyText: content?.trim() || (primaryImageUrl ? '(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}`,
@@ -554,7 +540,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
projectName: project.name, projectName: project.name,
videoTitle, videoTitle,
commentAuthor: commentAuthorName, commentAuthor: commentAuthorName,
commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'), commentText: content?.trim() || (primaryImageUrl ? '(image attachment)' : '(voice note)'),
timestamp: ts, timestamp: ts,
url: `${baseUrl}/watch/${version.video.id}`, url: `${baseUrl}/watch/${version.video.id}`,
}).catch((err) => logError('Notification failed:', err)); }).catch((err) => logError('Notification failed:', err));
@@ -50,7 +50,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
if (asset.provider === VideoAssetProvider.R2_IMAGE) { if (asset.provider === VideoAssetProvider.R2_IMAGE) {
const [assetReferenceCount, commentReferenceCount] = await Promise.all([ const [assetReferenceCount, commentReferenceCount] = await Promise.all([
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }), tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
tx.comment.count({ where: { imageUrl: asset.sourceUrl } }), tx.commentImage.count({ where: { url: asset.sourceUrl } }),
]); ]);
shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0; shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0;
} }
@@ -73,7 +73,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
if (asset.thumbnailUrl) { if (asset.thumbnailUrl) {
const [assetThumbnailCount, commentImageCount] = await Promise.all([ const [assetThumbnailCount, commentImageCount] = await Promise.all([
tx.videoAsset.count({ where: { thumbnailUrl: asset.thumbnailUrl } }), tx.videoAsset.count({ where: { thumbnailUrl: asset.thumbnailUrl } }),
tx.comment.count({ where: { imageUrl: asset.thumbnailUrl } }), tx.commentImage.count({ where: { url: asset.thumbnailUrl } }),
]); ]);
shouldDeleteVideoThumbnail = assetThumbnailCount === 0 && commentImageCount === 0; shouldDeleteVideoThumbnail = assetThumbnailCount === 0 && commentImageCount === 0;
} }
+2
View File
@@ -49,6 +49,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
annotationData: true, annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
@@ -72,6 +73,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
annotationData: true, annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
+21 -12
View File
@@ -436,14 +436,14 @@ export function VideoPageContent({
recordingTime, recordingTime,
audioBlob, audioBlob,
isUploadingAudio, isUploadingAudio,
imageBlob, imageFiles,
setImageBlob,
commentRangeStart, commentRangeStart,
commentRangeEnd, commentRangeEnd,
toggleCommentRangeSelection, toggleCommentRangeSelection,
clearCommentRangeSelection, clearCommentRangeSelection,
isUploadingImage, isUploadingImage,
imageInputRef, imageInputRef,
removeImageFile,
handleAddComment, handleAddComment,
handleImageSelect, handleImageSelect,
handlePaste, handlePaste,
@@ -460,8 +460,7 @@ export function VideoPageContent({
isReplyRecording, isReplyRecording,
replyRecordingTime, replyRecordingTime,
replyAudioBlob, replyAudioBlob,
replyImageBlob, replyImageFiles,
setReplyImageBlob,
replyRangeStart, replyRangeStart,
replyRangeEnd, replyRangeEnd,
toggleReplyRangeSelection, toggleReplyRangeSelection,
@@ -475,7 +474,6 @@ export function VideoPageContent({
cancelReplyRecording, cancelReplyRecording,
submitReplyWithMedia, submitReplyWithMedia,
editingCommentId, editingCommentId,
setEditingCommentId,
editText, editText,
setEditText, setEditText,
editTagId, editTagId,
@@ -484,6 +482,13 @@ export function VideoPageContent({
setEditAnnotationData, setEditAnnotationData,
isEditingAnnotation, isEditingAnnotation,
setIsEditingAnnotation, setIsEditingAnnotation,
editImageUrls,
editImageFiles,
editImageInputRef,
startEditingComment,
startEditingReply,
cancelEditingComment,
removeEditImageUrl,
isSubmittingEdit, isSubmittingEdit,
handleEditComment, handleEditComment,
handleDeleteComment, handleDeleteComment,
@@ -853,13 +858,17 @@ export function VideoPageContent({
currentUserId={currentUserId} currentUserId={currentUserId}
projectOwnerId={video.project.ownerId} projectOwnerId={video.project.ownerId}
editingCommentId={editingCommentId} editingCommentId={editingCommentId}
setEditingCommentId={setEditingCommentId} startEditingComment={startEditingComment}
startEditingReply={startEditingReply}
cancelEditingComment={cancelEditingComment}
editText={editText} editText={editText}
setEditText={setEditText} setEditText={setEditText}
editTagId={editTagId} editTagId={editTagId}
setEditTagId={setEditTagId} setEditTagId={setEditTagId}
setEditAnnotationData={setEditAnnotationData} editImageUrls={editImageUrls}
setIsEditingAnnotation={setIsEditingAnnotation} editImageFiles={editImageFiles}
editImageInputRef={editImageInputRef}
removeEditImageUrl={removeEditImageUrl}
onStartEditAnnotation={commentsActions.onStartEditAnnotation} onStartEditAnnotation={commentsActions.onStartEditAnnotation}
isSubmittingEdit={isSubmittingEdit} isSubmittingEdit={isSubmittingEdit}
availableTags={availableTags} availableTags={availableTags}
@@ -888,9 +897,9 @@ export function VideoPageContent({
stopReplyRecording={stopReplyRecording} stopReplyRecording={stopReplyRecording}
cancelReplyRecording={cancelReplyRecording} cancelReplyRecording={cancelReplyRecording}
replyAudioBlob={replyAudioBlob} replyAudioBlob={replyAudioBlob}
replyImageBlob={replyImageBlob} replyImageFiles={replyImageFiles}
setReplyImageBlob={setReplyImageBlob}
replyImageInputRef={replyImageInputRef} replyImageInputRef={replyImageInputRef}
removeImageFile={removeImageFile}
handleImageSelect={handleImageSelect} handleImageSelect={handleImageSelect}
handlePaste={handlePaste} handlePaste={handlePaste}
handleDrop={handleDrop} handleDrop={handleDrop}
@@ -931,9 +940,9 @@ export function VideoPageContent({
stopRecording={stopRecording} stopRecording={stopRecording}
cancelRecording={cancelRecording} cancelRecording={cancelRecording}
audioBlob={audioBlob} audioBlob={audioBlob}
imageBlob={imageBlob} imageFiles={imageFiles}
imageInputRef={imageInputRef} imageInputRef={imageInputRef}
setImageBlob={setImageBlob} removeImageFile={(index) => removeImageFile(index, 'comment')}
commentText={commentText} commentText={commentText}
setCommentText={setCommentText} setCommentText={setCommentText}
commentRangeStart={commentRangeStart} commentRangeStart={commentRangeStart}
+4 -4
View File
@@ -38,7 +38,7 @@ import { AssetListSection } from '@/components/video-page/asset-list-section';
import type { DirectUploadProvider, VideoAsset } from '@/components/video-page/types'; import type { DirectUploadProvider, VideoAsset } from '@/components/video-page/types';
import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload'; import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload';
import { import {
extractPastedImageFile, extractPastedImageFiles,
validateImageFile, validateImageFile,
} from '@/components/video-page/image-upload-utils'; } from '@/components/video-page/image-upload-utils';
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media'; import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
@@ -472,10 +472,10 @@ export const AssetsPane = memo(function AssetsPane({
const handleImagePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => { const handleImagePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => {
if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return; if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return;
const pastedImage = extractPastedImageFile(event.clipboardData); const pastedImages = extractPastedImageFiles(event.clipboardData);
if (!pastedImage) return; if (pastedImages.length === 0) return;
event.preventDefault(); event.preventDefault();
await stageImageFiles([pastedImage]); await stageImageFiles(pastedImages);
}; };
const handleCreateYoutubeAsset = async () => { const handleCreateYoutubeAsset = async () => {
+19 -66
View File
@@ -2,18 +2,7 @@
import { memo, type RefObject } from 'react'; import { memo, type RefObject } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, X } from 'lucide-react';
Image as ImageIcon,
Loader2,
Mic,
Pause,
Pencil,
Play,
Send,
Tag,
Trash2,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -23,6 +12,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import type { AnnotationStroke } from '@/components/annotation-canvas'; import type { AnnotationStroke } from '@/components/annotation-canvas';
import { ImageAttachmentStrip } from '@/components/video-page/image-attachments';
import { MAX_COMMENT_IMAGES } from '@/lib/comment-images';
import { MentionTextarea } from '@/components/video-page/mention-textarea'; import { MentionTextarea } from '@/components/video-page/mention-textarea';
import type { CommentTag, VideoAsset } from '@/components/video-page/types'; import type { CommentTag, VideoAsset } from '@/components/video-page/types';
@@ -32,9 +23,9 @@ interface CommentComposerProps {
stopRecording: () => void; stopRecording: () => void;
cancelRecording: () => void; cancelRecording: () => void;
audioBlob: Blob | null; audioBlob: Blob | null;
imageBlob: File | null; imageFiles: File[];
imageInputRef: RefObject<HTMLInputElement | null>; imageInputRef: RefObject<HTMLInputElement | null>;
setImageBlob: (blob: File | null) => void; removeImageFile: (index: number) => void;
commentText: string; commentText: string;
setCommentText: (value: string) => void; setCommentText: (value: string) => void;
commentRangeStart: number | null; commentRangeStart: number | null;
@@ -58,8 +49,8 @@ interface CommentComposerProps {
handleAddComment: () => void; handleAddComment: () => void;
isSubmittingComment: boolean; isSubmittingComment: boolean;
startRecording: () => void; startRecording: () => void;
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void; handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void; handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
availableTags: CommentTag[]; availableTags: CommentTag[];
selectedTagId: string | null; selectedTagId: string | null;
setSelectedTagId: (value: string | null) => void; setSelectedTagId: (value: string | null) => void;
@@ -75,9 +66,9 @@ export const CommentComposer = memo(function CommentComposer({
stopRecording, stopRecording,
cancelRecording, cancelRecording,
audioBlob, audioBlob,
imageBlob, imageFiles,
imageInputRef, imageInputRef,
setImageBlob, removeImageFile,
commentText, commentText,
setCommentText, setCommentText,
commentRangeStart, commentRangeStart,
@@ -179,28 +170,7 @@ export const CommentComposer = memo(function CommentComposer({
</Button> </Button>
</div> </div>
{imageBlob && ( <ImageAttachmentStrip files={imageFiles} onRemoveFile={removeImageFile} />
<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>
)}
<MentionTextarea <MentionTextarea
placeholder="Add a note to your voice comment (optional)..." placeholder="Add a note to your voice comment (optional)..."
@@ -271,28 +241,7 @@ export const CommentComposer = memo(function CommentComposer({
</button> </button>
</div> </div>
)} )}
{imageBlob && ( <ImageAttachmentStrip files={imageFiles} onRemoveFile={removeImageFile} />
<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="mb-2 flex items-center gap-2 flex-wrap"> <div className="mb-2 flex items-center gap-2 flex-wrap">
<Button <Button
size="sm" size="sm"
@@ -332,7 +281,7 @@ export const CommentComposer = memo(function CommentComposer({
handleAddComment(); handleAddComment();
} }
}} }}
onPaste={(e) => handlePaste(e, false)} onPaste={handlePaste}
/> />
</div> </div>
<div className="flex flex-col gap-1 self-end"> <div className="flex flex-col gap-1 self-end">
@@ -340,7 +289,7 @@ export const CommentComposer = memo(function CommentComposer({
size="icon" size="icon"
onClick={handleAddComment} onClick={handleAddComment}
disabled={ disabled={
(!commentText.trim() && !imageBlob && !annotationStrokes) || (!commentText.trim() && imageFiles.length === 0 && !annotationStrokes) ||
isSubmittingComment || isSubmittingComment ||
isUploadingImage isUploadingImage
} }
@@ -363,7 +312,8 @@ export const CommentComposer = memo(function CommentComposer({
size="icon" size="icon"
variant="outline" variant="outline"
onClick={() => imageInputRef.current?.click()} onClick={() => imageInputRef.current?.click()}
title="Attach Image" disabled={imageFiles.length >= MAX_COMMENT_IMAGES}
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
> >
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
</Button> </Button>
@@ -387,6 +337,7 @@ export const CommentComposer = memo(function CommentComposer({
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
multiple
className="hidden" className="hidden"
ref={imageInputRef} ref={imageInputRef}
onChange={handleImageSelect} onChange={handleImageSelect}
@@ -444,7 +395,9 @@ export const CommentComposer = memo(function CommentComposer({
)} )}
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground mt-2">Cmd+Enter to submit</p> <p className="text-xs text-muted-foreground mt-2">
Cmd+Enter to submit &middot; paste or drop up to {MAX_COMMENT_IMAGES} images
</p>
</> </>
)} )}
</div> </div>
+138 -127
View File
@@ -36,7 +36,19 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MentionTextarea } from '@/components/video-page/mention-textarea'; import { MentionTextarea } from '@/components/video-page/mention-textarea';
import { CommentRichText } from '@/components/video-page/comment-rich-text'; import { CommentRichText } from '@/components/video-page/comment-rich-text';
import type { Comment, CommentTag, Version, VideoAsset } from '@/components/video-page/types'; import {
CommentImageGallery,
ImageAttachmentStrip,
} from '@/components/video-page/image-attachments';
import type { ImageAttachTarget } from '@/components/video-page/hooks/use-comment-actions';
import { MAX_COMMENT_IMAGES } from '@/lib/comment-images';
import type {
Comment,
CommentReply,
CommentTag,
Version,
VideoAsset,
} from '@/components/video-page/types';
interface CommentsPaneProps { interface CommentsPaneProps {
isMobileCommentsOpen: boolean; isMobileCommentsOpen: boolean;
@@ -63,13 +75,17 @@ interface CommentsPaneProps {
currentUserId: string | null; currentUserId: string | null;
projectOwnerId: string; projectOwnerId: string;
editingCommentId: string | null; editingCommentId: string | null;
setEditingCommentId: (id: string | null) => void; startEditingComment: (comment: Comment) => void;
startEditingReply: (reply: CommentReply) => void;
cancelEditingComment: () => void;
editText: string; editText: string;
setEditText: (value: string) => void; setEditText: (value: string) => void;
editTagId: string | null | undefined; editTagId: string | null | undefined;
setEditTagId: (value: string | null | undefined) => void; setEditTagId: (value: string | null | undefined) => void;
setEditAnnotationData: (value: string | null | undefined) => void; editImageUrls: string[];
setIsEditingAnnotation: (value: boolean) => void; editImageFiles: File[];
editImageInputRef: RefObject<HTMLInputElement | null>;
removeEditImageUrl: (url: string) => void;
onStartEditAnnotation: () => void; onStartEditAnnotation: () => void;
isSubmittingEdit: boolean; isSubmittingEdit: boolean;
availableTags: CommentTag[]; availableTags: CommentTag[];
@@ -94,7 +110,7 @@ interface CommentsPaneProps {
handleReplyComment: ( handleReplyComment: (
parentId: string, parentId: string,
voiceData?: { url: string; duration: number }, voiceData?: { url: string; duration: number },
imageData?: { url: string } imageUrls?: string[]
) => void; ) => void;
startReplyRecording: () => void; startReplyRecording: () => void;
isReplyRecording: boolean; isReplyRecording: boolean;
@@ -102,12 +118,12 @@ interface CommentsPaneProps {
stopReplyRecording: () => void; stopReplyRecording: () => void;
cancelReplyRecording: () => void; cancelReplyRecording: () => void;
replyAudioBlob: Blob | null; replyAudioBlob: Blob | null;
replyImageBlob: File | null; replyImageFiles: File[];
setReplyImageBlob: (file: File | null) => void;
replyImageInputRef: RefObject<HTMLInputElement | null>; replyImageInputRef: RefObject<HTMLInputElement | null>;
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void; removeImageFile: (index: number, target: ImageAttachTarget) => void;
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void; handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, target?: ImageAttachTarget) => void;
handleDrop: (e: React.DragEvent<HTMLDivElement>, isReply?: boolean) => void; handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, target?: ImageAttachTarget) => void;
handleDrop: (e: React.DragEvent<HTMLDivElement>, target?: ImageAttachTarget) => void;
submitReplyWithMedia: (parentId: string) => void; submitReplyWithMedia: (parentId: string) => void;
isSubmittingReply: boolean; isSubmittingReply: boolean;
isUploadingReplyAudio: boolean; isUploadingReplyAudio: boolean;
@@ -141,13 +157,17 @@ export const CommentsPane = memo(function CommentsPane({
currentUserId, currentUserId,
projectOwnerId, projectOwnerId,
editingCommentId, editingCommentId,
setEditingCommentId, startEditingComment,
startEditingReply,
cancelEditingComment,
editText, editText,
setEditText, setEditText,
editTagId, editTagId,
setEditTagId, setEditTagId,
setEditAnnotationData, editImageUrls,
setIsEditingAnnotation, editImageFiles,
editImageInputRef,
removeEditImageUrl,
onStartEditAnnotation, onStartEditAnnotation,
isSubmittingEdit, isSubmittingEdit,
availableTags, availableTags,
@@ -176,9 +196,9 @@ export const CommentsPane = memo(function CommentsPane({
stopReplyRecording, stopReplyRecording,
cancelReplyRecording, cancelReplyRecording,
replyAudioBlob, replyAudioBlob,
replyImageBlob, replyImageFiles,
setReplyImageBlob,
replyImageInputRef, replyImageInputRef,
removeImageFile,
handleImageSelect, handleImageSelect,
handlePaste, handlePaste,
handleDrop, handleDrop,
@@ -241,12 +261,15 @@ export const CommentsPane = memo(function CommentsPane({
onDrop={(e) => { onDrop={(e) => {
setIsPaneDraggingOver(false); setIsPaneDraggingOver(false);
if (activePane !== 'comments') return; if (activePane !== 'comments') return;
handleDrop(e, replyingTo !== null); handleDrop(
e,
editingCommentId !== null ? 'edit' : replyingTo !== null ? 'reply' : 'comment'
);
}} }}
> >
{isPaneDraggingOver && ( {isPaneDraggingOver && (
<div className="absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-primary bg-primary/10 pointer-events-none"> <div className="absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-primary bg-primary/10 pointer-events-none">
<p className="text-sm font-medium text-primary">Drop image to attach</p> <p className="text-sm font-medium text-primary">Drop images to attach</p>
</div> </div>
)} )}
<div className="shrink-0 p-4 border-b lg:cursor-default space-y-2"> <div className="shrink-0 p-4 border-b lg:cursor-default space-y-2">
@@ -446,13 +469,7 @@ export const CommentsPane = memo(function CommentsPane({
Reply Reply
</DropdownMenuItem> </DropdownMenuItem>
{canEditComment && ( {canEditComment && (
<DropdownMenuItem <DropdownMenuItem onClick={() => startEditingComment(comment)}>
onClick={() => {
setEditingCommentId(comment.id);
setEditText(comment.content || '');
setEditTagId(comment.tag?.id || null);
}}
>
<Pencil className="h-4 w-4 mr-2" /> <Pencil className="h-4 w-4 mr-2" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@@ -486,19 +503,28 @@ export const CommentsPane = memo(function CommentsPane({
handleEditComment(comment.id); handleEditComment(comment.id);
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
setEditingCommentId(null); cancelEditingComment();
setEditText('');
setEditTagId(undefined);
setEditAnnotationData(undefined);
setIsEditingAnnotation(false);
} }
}} }}
onPaste={(e) => handlePaste(e, 'edit')}
/>
<ImageAttachmentStrip
existingUrls={editImageUrls}
onRemoveExisting={removeEditImageUrl}
files={editImageFiles}
onRemoveFile={(index) => removeImageFile(index, 'edit')}
compact
/> />
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
<Button <Button
size="sm" size="sm"
onClick={() => handleEditComment(comment.id)} onClick={() => handleEditComment(comment.id)}
disabled={!editText.trim() || isSubmittingEdit} disabled={
(!editText.trim() &&
editImageUrls.length === 0 &&
editImageFiles.length === 0) ||
isSubmittingEdit
}
className="h-7 text-xs" className="h-7 text-xs"
> >
{isSubmittingEdit ? ( {isSubmittingEdit ? (
@@ -510,17 +536,31 @@ export const CommentsPane = memo(function CommentsPane({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { onClick={cancelEditingComment}
setEditingCommentId(null);
setEditText('');
setEditTagId(undefined);
setEditAnnotationData(undefined);
setIsEditingAnnotation(false);
}}
className="h-7 text-xs" className="h-7 text-xs"
> >
Cancel Cancel
</Button> </Button>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() => editImageInputRef.current?.click()}
disabled={
editImageUrls.length + editImageFiles.length >= MAX_COMMENT_IMAGES
}
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
>
<ImageIcon className="h-3.5 w-3.5" />
</Button>
<input
type="file"
accept="image/*"
multiple
className="hidden"
ref={editImageInputRef}
onChange={(e) => handleImageSelect(e, 'edit')}
/>
<Button <Button
size="icon" size="icon"
variant={comment.annotationData ? 'default' : 'outline'} variant={comment.annotationData ? 'default' : 'outline'}
@@ -595,21 +635,13 @@ export const CommentsPane = memo(function CommentsPane({
/> />
</p> </p>
)} )}
{comment.imageUrl && ( <CommentImageGallery
<div images={comment.images}
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity" onOpen={setPreviewImage}
onClick={() => setPreviewImage(comment.imageUrl)} className="mb-2"
>
{/* 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>
)} )}
</div>
)}
{comment.voiceUrl && ( {comment.voiceUrl && (
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2"> <div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
@@ -722,15 +754,7 @@ export const CommentsPane = memo(function CommentsPane({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{canEditReply && ( {canEditReply && (
<DropdownMenuItem <DropdownMenuItem onClick={() => startEditingReply(reply)}>
onClick={() => {
setEditingCommentId(reply.id);
setEditText(reply.content || '');
// No tag picker on a reply: undefined keeps
// the PATCH from carrying a tagId at all.
setEditTagId(undefined);
}}
>
<Pencil className="h-4 w-4 mr-2" /> <Pencil className="h-4 w-4 mr-2" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@@ -762,16 +786,28 @@ export const CommentsPane = memo(function CommentsPane({
handleEditComment(reply.id); handleEditComment(reply.id);
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
setEditingCommentId(null); cancelEditingComment();
setEditText('');
} }
}} }}
onPaste={(e) => handlePaste(e, 'edit')}
/>
<ImageAttachmentStrip
existingUrls={editImageUrls}
onRemoveExisting={removeEditImageUrl}
files={editImageFiles}
onRemoveFile={(index) => removeImageFile(index, 'edit')}
compact
/> />
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
size="sm" size="sm"
onClick={() => handleEditComment(reply.id)} onClick={() => handleEditComment(reply.id)}
disabled={!editText.trim() || isSubmittingEdit} disabled={
(!editText.trim() &&
editImageUrls.length === 0 &&
editImageFiles.length === 0) ||
isSubmittingEdit
}
className="h-7 text-xs" className="h-7 text-xs"
> >
{isSubmittingEdit ? ( {isSubmittingEdit ? (
@@ -783,14 +819,32 @@ export const CommentsPane = memo(function CommentsPane({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { onClick={cancelEditingComment}
setEditingCommentId(null);
setEditText('');
}}
className="h-7 text-xs" className="h-7 text-xs"
> >
Cancel Cancel
</Button> </Button>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() => editImageInputRef.current?.click()}
disabled={
editImageUrls.length + editImageFiles.length >=
MAX_COMMENT_IMAGES
}
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
>
<ImageIcon className="h-3.5 w-3.5" />
</Button>
<input
type="file"
accept="image/*"
multiple
className="hidden"
ref={editImageInputRef}
onChange={(e) => handleImageSelect(e, 'edit')}
/>
</div> </div>
</div> </div>
) : ( ) : (
@@ -804,21 +858,14 @@ export const CommentsPane = memo(function CommentsPane({
/> />
</p> </p>
)} )}
{reply.imageUrl && ( <CommentImageGallery
<div images={reply.images}
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity" onOpen={setPreviewImage}
onClick={() => setPreviewImage(reply.imageUrl)} compact
> className="mt-2"
{/* 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>
)} )}
</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">
<Button <Button
@@ -943,30 +990,11 @@ export const CommentsPane = memo(function CommentsPane({
</Button> </Button>
</div> </div>
{replyImageBlob && ( <ImageAttachmentStrip
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2"> files={replyImageFiles}
{/* eslint-disable-next-line @next/next/no-img-element */} onRemoveFile={(index) => removeImageFile(index, 'reply')}
<img compact
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>
)}
<MentionTextarea <MentionTextarea
value={replyText} value={replyText}
@@ -1026,30 +1054,11 @@ export const CommentsPane = memo(function CommentsPane({
</div> </div>
) : ( ) : (
<> <>
{replyImageBlob && ( <ImageAttachmentStrip
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2"> files={replyImageFiles}
{/* eslint-disable-next-line @next/next/no-img-element */} onRemoveFile={(index) => removeImageFile(index, 'reply')}
<img compact
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">
<MentionTextarea <MentionTextarea
value={replyText} value={replyText}
@@ -1069,7 +1078,7 @@ export const CommentsPane = memo(function CommentsPane({
setReplyText(''); setReplyText('');
} }
}} }}
onPaste={(e) => handlePaste(e, true)} onPaste={(e) => handlePaste(e, 'reply')}
/> />
<Button <Button
size="icon" size="icon"
@@ -1084,7 +1093,8 @@ export const CommentsPane = memo(function CommentsPane({
size="icon" size="icon"
variant="outline" variant="outline"
onClick={() => replyImageInputRef.current?.click()} onClick={() => replyImageInputRef.current?.click()}
title="Attach Image" disabled={replyImageFiles.length >= MAX_COMMENT_IMAGES}
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
className="h-8 w-8 shrink-0 self-end" className="h-8 w-8 shrink-0 self-end"
> >
<ImageIcon className="h-3 w-3" /> <ImageIcon className="h-3 w-3" />
@@ -1092,9 +1102,10 @@ export const CommentsPane = memo(function CommentsPane({
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
multiple
className="hidden" className="hidden"
ref={replyImageInputRef} ref={replyImageInputRef}
onChange={(e) => handleImageSelect(e, true)} onChange={(e) => handleImageSelect(e, 'reply')}
/> />
</div> </div>
<div className="mt-2 flex items-center gap-2 flex-wrap"> <div className="mt-2 flex items-center gap-2 flex-wrap">
@@ -1127,7 +1138,7 @@ export const CommentsPane = memo(function CommentsPane({
size="sm" size="sm"
onClick={() => handleReplyComment(comment.id)} onClick={() => handleReplyComment(comment.id)}
disabled={ disabled={
(!replyText.trim() && !replyImageBlob) || (!replyText.trim() && replyImageFiles.length === 0) ||
isSubmittingReply || isSubmittingReply ||
isUploadingReplyImage isUploadingReplyImage
} }
+245 -137
View File
@@ -17,15 +17,17 @@ import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/anno
import type { import type {
Comment, Comment,
CommentActionsConfig, CommentActionsConfig,
CommentImage,
CommentReply, CommentReply,
CommentTag, CommentTag,
Version, Version,
VideoData, VideoData,
} from '@/components/video-page/types'; } from '@/components/video-page/types';
import { import {
extractPastedImageFile, extractPastedImageFiles,
validateImageFile, validateImageFile,
} from '@/components/video-page/image-upload-utils'; } from '@/components/video-page/image-upload-utils';
import { MAX_COMMENT_IMAGES } from '@/lib/comment-images';
import { validateAnnotationStrokes } from '@/lib/validation'; import { validateAnnotationStrokes } from '@/lib/validation';
import { withWebmDuration } from '@/lib/webm-duration'; import { withWebmDuration } from '@/lib/webm-duration';
import { ApiRequestError, apiRequestError, toastApiError } from '@/lib/client/api-error'; import { ApiRequestError, apiRequestError, toastApiError } from '@/lib/client/api-error';
@@ -53,6 +55,9 @@ interface UseCommentActionsParams extends CommentActionsConfig {
fetchAssets: () => Promise<void>; fetchAssets: () => Promise<void>;
} }
/** Which of the three editors an attachment is being staged for. */
export type ImageAttachTarget = 'comment' | 'reply' | 'edit';
function getAudioUploadFilename(blob: Blob): string { function getAudioUploadFilename(blob: Blob): string {
const mime = blob.type.split(';')[0].trim().toLowerCase(); const mime = blob.type.split(';')[0].trim().toLowerCase();
if (mime === 'audio/mp4') return 'recording.m4a'; if (mime === 'audio/mp4') return 'recording.m4a';
@@ -91,7 +96,7 @@ export function useCommentActions({
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 [imageFiles, setImageFiles] = useState<File[]>([]);
const [isUploadingImage, setIsUploadingImage] = useState(false); const [isUploadingImage, setIsUploadingImage] = useState(false);
const [commentRangeStart, setCommentRangeStart] = useState<number | null>(null); const [commentRangeStart, setCommentRangeStart] = useState<number | null>(null);
const [commentRangeEnd, setCommentRangeEnd] = useState<number | null>(null); const [commentRangeEnd, setCommentRangeEnd] = useState<number | null>(null);
@@ -108,7 +113,7 @@ export function useCommentActions({
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 [replyImageFiles, setReplyImageFiles] = useState<File[]>([]);
const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false); const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false);
const [replyRangeStart, setReplyRangeStart] = useState<number | null>(null); const [replyRangeStart, setReplyRangeStart] = useState<number | null>(null);
const [replyRangeEnd, setReplyRangeEnd] = useState<number | null>(null); const [replyRangeEnd, setReplyRangeEnd] = useState<number | null>(null);
@@ -130,6 +135,10 @@ export function useCommentActions({
undefined undefined
); );
const [isEditingAnnotation, setIsEditingAnnotation] = useState(false); const [isEditingAnnotation, setIsEditingAnnotation] = useState(false);
// The images the edited comment keeps, and the ones staged to be added to it.
const [editImageUrls, setEditImageUrls] = useState<string[]>([]);
const [editImageFiles, setEditImageFiles] = useState<File[]>([]);
const editImageInputRef = useRef<HTMLInputElement>(null);
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
@@ -189,9 +198,98 @@ export function useCommentActions({
[isGuest, videoId] [isGuest, videoId]
); );
/** Upload a batch of staged images and hand back their URLs, in the same order. */
const uploadImageFiles = useCallback(
async (files: File[]): Promise<string[]> => {
if (files.length === 0) return [];
// One grant covers the whole batch: it is bound to the intent and the
// client, not to a single file.
const uploadToken = await getGuestUploadToken('image');
return Promise.all(
files.map(async (file) => {
const formData = new FormData();
formData.append('image', file);
formData.append('videoId', videoId);
if (uploadToken) formData.append('uploadToken', uploadToken);
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
if (!response.ok) {
// The attachments go up before the comment does, so a full account
// fails here and never reaches the comment at all. Thrown with the
// code attached so the caller can offer the way out.
const payload = (await response.json().catch(() => null)) as {
error?: string;
code?: string;
} | null;
throw apiRequestError(payload, 'Failed to upload image');
}
const payload = await response.json();
return payload.data.url as string;
})
);
},
[getGuestUploadToken, videoId]
);
/** Stage validated images on one of the editors, up to the per-comment cap. */
const attachImageFiles = useCallback(
async (files: File[], target: ImageAttachTarget) => {
if (files.length === 0) return;
for (const file of files) {
const imageError = await validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
}
const staged =
target === 'reply' ? replyImageFiles : target === 'edit' ? editImageFiles : imageFiles;
// Images the edited comment already has count against the same cap.
const alreadyOnComment = target === 'edit' ? editImageUrls.length : 0;
const room = MAX_COMMENT_IMAGES - alreadyOnComment - staged.length;
if (room <= 0) {
toast.error(`A comment can have at most ${MAX_COMMENT_IMAGES} images`);
return;
}
if (files.length > room) {
toast.error(
room === 1
? 'Only 1 more image fits on this comment'
: `Only ${room} more images fit on this comment`
);
}
const next = [...staged, ...files.slice(0, room)];
if (target === 'reply') setReplyImageFiles(next);
else if (target === 'edit') setEditImageFiles(next);
else setImageFiles(next);
},
[editImageFiles, editImageUrls, imageFiles, replyImageFiles]
);
const removeImageFile = useCallback((index: number, target: ImageAttachTarget) => {
const drop = (files: File[]) => files.filter((_, current) => current !== index);
if (target === 'reply') setReplyImageFiles(drop);
else if (target === 'edit') setEditImageFiles(drop);
else setImageFiles(drop);
}, []);
const handleAddComment = useCallback( const handleAddComment = useCallback(
async (voiceData?: { url: string; duration: number }) => { async (voiceData?: { url: string; duration: number }) => {
if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating) if (
!voiceData &&
imageFiles.length === 0 &&
!commentText.trim() &&
!annotationStrokes &&
!isAnnotating
)
return; return;
if (!activeVersion || !activeVersionId) return; if (!activeVersion || !activeVersionId) return;
@@ -206,14 +304,18 @@ export function useCommentActions({
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
const commentTimestamp = commentRangeStart ?? currentTime; const commentTimestamp = commentRangeStart ?? currentTime;
const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null; const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null;
const hasImages = imageFiles.length > 0;
const optimisticComment: Comment = { const optimisticComment: Comment = {
id: tempId, id: tempId,
content: voiceData || imageBlob ? commentText.trim() || null : commentText, content: voiceData || hasImages ? commentText.trim() || null : commentText,
timestamp: commentTimestamp, timestamp: commentTimestamp,
timestampEnd: commentRangeEnd, timestampEnd: commentRangeEnd,
voiceUrl: voiceData?.url ?? null, voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null, voiceDuration: voiceData?.duration ?? null,
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null, images: imageFiles.map((file, index) => ({
id: `${tempId}-image-${index}`,
url: URL.createObjectURL(file),
})),
annotationData: serializedAnnotation, annotationData: serializedAnnotation,
isResolved: false, isResolved: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -238,7 +340,7 @@ export function useCommentActions({
setCommentText(''); setCommentText('');
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null); setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
setAudioBlob(null); setAudioBlob(null);
setImageBlob(null); setImageFiles([]);
setAnnotationStrokes(null); setAnnotationStrokes(null);
setIsAnnotating(false); setIsAnnotating(false);
clearCommentRangeSelection(); clearCommentRangeSelection();
@@ -248,44 +350,22 @@ export function useCommentActions({
isMutatingRef.current = true; isMutatingRef.current = true;
try { try {
let imageData: { url: string } | undefined; let uploadedImageUrls: string[] = [];
if (imageBlob) { if (hasImages) {
setIsUploadingImage(true); setIsUploadingImage(true);
const imageFormData = new FormData(); uploadedImageUrls = await uploadImageFiles(imageFiles);
imageFormData.append('image', imageBlob);
imageFormData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('image');
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
const imageRes = await fetch('/api/upload/image', {
method: 'POST',
body: imageFormData,
});
if (!imageRes.ok) {
// The attachment goes up before the comment does, so a full account
// fails here and never reaches the comment at all. Thrown with the
// code attached so the catch below can offer the way out.
const imagePayload = (await imageRes.json().catch(() => null)) as {
error?: string;
code?: string;
} | null;
throw apiRequestError(imagePayload, '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 || imageBlob ? commentText.trim() || null : commentText, content: voiceData || hasImages ? commentText.trim() || null : commentText,
timestamp: commentTimestamp, timestamp: commentTimestamp,
...(commentRangeEnd !== null && { timestampEnd: commentRangeEnd }), ...(commentRangeEnd !== null && { timestampEnd: commentRangeEnd }),
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(imageData && { imageUrl: imageData.url }), ...(uploadedImageUrls.length > 0 && { imageUrls: uploadedImageUrls }),
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }), ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
...(selectedTagId && { tagId: selectedTagId }), ...(selectedTagId && { tagId: selectedTagId }),
...(effectiveStrokes && { annotationData: effectiveStrokes }), ...(effectiveStrokes && { annotationData: effectiveStrokes }),
@@ -314,8 +394,8 @@ export function useCommentActions({
}; };
}); });
// If an image was attached, refresh the assets list // If images were attached, refresh the assets list
if (imageData) { if (uploadedImageUrls.length > 0) {
void fetchAssets(); void fetchAssets();
} }
} else { } else {
@@ -369,11 +449,10 @@ export function useCommentActions({
currentUserName, currentUserName,
selectedTagId, selectedTagId,
availableTags, availableTags,
imageBlob, imageFiles,
uploadImageFiles,
annotationStrokes, annotationStrokes,
isAnnotating, isAnnotating,
videoId,
getGuestUploadToken,
annotationCanvasRef, annotationCanvasRef,
setSelectedTagId, setSelectedTagId,
setAnnotationStrokes, setAnnotationStrokes,
@@ -386,63 +465,34 @@ export function useCommentActions({
); );
const handleImageSelect = useCallback( const handleImageSelect = useCallback(
async (e: ChangeEvent<HTMLInputElement>, isReply: boolean = false) => { async (e: ChangeEvent<HTMLInputElement>, target: ImageAttachTarget = 'comment') => {
const file = e.target.files?.[0]; const files = Array.from(e.target.files ?? []);
if (!file) return; // Clearing the input lets the same file be picked again after it is removed.
e.target.value = '';
const imageError = await validateImageFile(file); await attachImageFiles(files, target);
if (imageError) {
toast.error(imageError);
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
}, },
[] [attachImageFiles]
); );
const handlePaste = useCallback( const handlePaste = useCallback(
async (e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => { async (e: ClipboardEvent<HTMLTextAreaElement>, target: ImageAttachTarget = 'comment') => {
const file = extractPastedImageFile(e.clipboardData); const files = extractPastedImageFiles(e.clipboardData);
if (!file) return; if (files.length === 0) return;
e.preventDefault(); e.preventDefault();
await attachImageFiles(files, target);
const imageError = await validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
}, },
[] [attachImageFiles]
); );
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>, isReply: boolean = false) => { const handleDrop = useCallback(
async (e: DragEvent<HTMLDivElement>, target: ImageAttachTarget = 'comment') => {
e.preventDefault(); e.preventDefault();
const file = extractPastedImageFile(e.dataTransfer); const files = extractPastedImageFiles(e.dataTransfer);
if (!file) return; if (files.length === 0) return;
await attachImageFiles(files, target);
const imageError = await validateImageFile(file); },
if (imageError) { [attachImageFiles]
toast.error(imageError); );
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
}, []);
const startRecording = useCallback(async () => { const startRecording = useCallback(async () => {
try { try {
@@ -543,13 +593,13 @@ export function useCommentActions({
const submitCommentWithMedia = useCallback(async () => { const submitCommentWithMedia = useCallback(async () => {
if (!activeVersion) return; if (!activeVersion) return;
if (audioBlob && !imageBlob && !commentText.trim()) { if (audioBlob && imageFiles.length === 0 && !commentText.trim()) {
submitVoiceComment(); submitVoiceComment();
return; return;
} }
if (audioBlob) setIsUploadingAudio(true); if (audioBlob) setIsUploadingAudio(true);
if (imageBlob) setIsUploadingImage(true); if (imageFiles.length > 0) setIsUploadingImage(true);
try { try {
let voiceData: { url: string; duration: number } | undefined; let voiceData: { url: string; duration: number } | undefined;
@@ -569,7 +619,7 @@ export function useCommentActions({
setAudioBlob(null); setAudioBlob(null);
setRecordingTime(0); setRecordingTime(0);
setImageBlob(null); setImageFiles([]);
if (imageInputRef.current) imageInputRef.current.value = ''; if (imageInputRef.current) imageInputRef.current.value = '';
} catch (err) { } catch (err) {
console.error('Failed to submit comment with media:', err); console.error('Failed to submit comment with media:', err);
@@ -580,7 +630,7 @@ export function useCommentActions({
} }
}, [ }, [
audioBlob, audioBlob,
imageBlob, imageFiles,
activeVersion, activeVersion,
recordingTime, recordingTime,
commentText, commentText,
@@ -676,21 +726,25 @@ export function useCommentActions({
async ( async (
parentId: string, parentId: string,
voiceData?: { url: string; duration: number }, voiceData?: { url: string; duration: number },
imageData?: { url: string } alreadyUploadedImageUrls?: string[]
) => { ) => {
if (!voiceData && !replyImageBlob && !replyText.trim()) return; if (!voiceData && replyImageFiles.length === 0 && !replyText.trim()) return;
if (!activeVersion || !activeVersionId) return; if (!activeVersion || !activeVersionId) return;
const hasReplyImages = replyImageFiles.length > 0;
const tempId = `temp-reply-${Date.now()}`; const tempId = `temp-reply-${Date.now()}`;
const replyTimestamp = replyRangeStart ?? currentTime; const replyTimestamp = replyRangeStart ?? currentTime;
const optimisticReply: CommentReply = { const optimisticReply: CommentReply = {
id: tempId, id: tempId,
content: voiceData || replyImageBlob ? replyText.trim() || null : replyText, content: voiceData || hasReplyImages ? replyText.trim() || null : replyText,
timestamp: replyTimestamp, timestamp: replyTimestamp,
timestampEnd: replyRangeEnd, timestampEnd: replyRangeEnd,
voiceUrl: voiceData?.url ?? null, voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null, voiceDuration: voiceData?.duration ?? null,
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null, images: replyImageFiles.map((file, index) => ({
id: `${tempId}-image-${index}`,
url: URL.createObjectURL(file),
})),
annotationData: null, annotationData: 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 },
@@ -723,43 +777,31 @@ export function useCommentActions({
setReplyingTo(null); setReplyingTo(null);
setReplyAudioBlob(null); setReplyAudioBlob(null);
setReplyRecordingTime(0); setReplyRecordingTime(0);
setReplyImageBlob(null); setReplyImageFiles([]);
clearReplyRangeSelection(); clearReplyRangeSelection();
setIsSubmittingReply(true); setIsSubmittingReply(true);
isMutatingRef.current = true; isMutatingRef.current = true;
try { try {
let submittedImageData: { url: string } | undefined = imageData; let submittedImageUrls: string[] = alreadyUploadedImageUrls ?? [];
if (replyImageBlob && !imageData) { if (hasReplyImages && submittedImageUrls.length === 0) {
setIsUploadingReplyImage(true); setIsUploadingReplyImage(true);
const imageFormData = new FormData(); submittedImageUrls = await uploadImageFiles(replyImageFiles);
imageFormData.append('image', replyImageBlob);
imageFormData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('image');
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
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 || submittedImageData ? replyText.trim() || null : replyText, content:
voiceData || submittedImageUrls.length > 0 ? replyText.trim() || null : replyText,
timestamp: replyTimestamp, timestamp: replyTimestamp,
...(replyRangeEnd !== null && { timestampEnd: replyRangeEnd }), ...(replyRangeEnd !== null && { timestampEnd: replyRangeEnd }),
parentId, parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(submittedImageData && { imageUrl: submittedImageData.url }), ...(submittedImageUrls.length > 0 && { imageUrls: submittedImageUrls }),
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }), ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
}), }),
}); });
@@ -791,8 +833,8 @@ export function useCommentActions({
}; };
}); });
// If an image was attached, refresh the assets list // If images were attached, refresh the assets list
if (submittedImageData) { if (submittedImageUrls.length > 0) {
void fetchAssets(); void fetchAssets();
} }
} else { } else {
@@ -852,9 +894,8 @@ export function useCommentActions({
isGuest, isGuest,
normalizedGuestName, normalizedGuestName,
currentUserName, currentUserName,
replyImageBlob, replyImageFiles,
videoId, uploadImageFiles,
getGuestUploadToken,
setVideo, setVideo,
fetchAssets, fetchAssets,
clearReplyRangeSelection, clearReplyRangeSelection,
@@ -949,13 +990,13 @@ export function useCommentActions({
async (parentId: string) => { async (parentId: string) => {
if (!activeVersion) return; if (!activeVersion) return;
if (replyAudioBlob && !replyImageBlob && !replyText.trim()) { if (replyAudioBlob && replyImageFiles.length === 0 && !replyText.trim()) {
submitVoiceReply(parentId); submitVoiceReply(parentId);
return; return;
} }
if (replyAudioBlob) setIsUploadingReplyAudio(true); if (replyAudioBlob) setIsUploadingReplyAudio(true);
if (replyImageBlob) setIsUploadingReplyImage(true); if (replyImageFiles.length > 0) setIsUploadingReplyImage(true);
try { try {
let voiceData: { url: string; duration: number } | undefined; let voiceData: { url: string; duration: number } | undefined;
@@ -976,7 +1017,7 @@ export function useCommentActions({
setReplyAudioBlob(null); setReplyAudioBlob(null);
setReplyRecordingTime(0); setReplyRecordingTime(0);
setReplyImageBlob(null); setReplyImageFiles([]);
if (replyImageInputRef.current) replyImageInputRef.current.value = ''; if (replyImageInputRef.current) replyImageInputRef.current.value = '';
} catch (err) { } catch (err) {
console.error('Failed to submit reply with media:', err); console.error('Failed to submit reply with media:', err);
@@ -988,7 +1029,7 @@ export function useCommentActions({
}, },
[ [
replyAudioBlob, replyAudioBlob,
replyImageBlob, replyImageFiles,
activeVersion, activeVersion,
replyRecordingTime, replyRecordingTime,
replyText, replyText,
@@ -999,9 +1040,41 @@ export function useCommentActions({
] ]
); );
const startEditingComment = useCallback((comment: Comment) => {
setEditingCommentId(comment.id);
setEditText(comment.content || '');
setEditTagId(comment.tag?.id || null);
setEditImageUrls(comment.images.map((image) => image.url));
setEditImageFiles([]);
}, []);
const startEditingReply = useCallback((reply: CommentReply) => {
setEditingCommentId(reply.id);
setEditText(reply.content || '');
// No tag picker on a reply: undefined keeps the PATCH from carrying a tagId at all.
setEditTagId(undefined);
setEditImageUrls(reply.images.map((image) => image.url));
setEditImageFiles([]);
}, []);
const cancelEditingComment = useCallback(() => {
setEditingCommentId(null);
setEditText('');
setEditTagId(undefined);
setEditAnnotationData(undefined);
setIsEditingAnnotation(false);
setEditImageUrls([]);
setEditImageFiles([]);
}, []);
const removeEditImageUrl = useCallback((url: string) => {
setEditImageUrls((prev) => prev.filter((current) => current !== url));
}, []);
const handleEditComment = useCallback( const handleEditComment = useCallback(
async (commentId: string) => { async (commentId: string) => {
if (!editText.trim() && !editAnnotationData) return; const keepsImages = editImageUrls.length > 0 || editImageFiles.length > 0;
if (!editText.trim() && !editAnnotationData && !keepsImages) return;
if (!activeVersionId) return; if (!activeVersionId) return;
setIsSubmittingEdit(true); setIsSubmittingEdit(true);
@@ -1016,7 +1089,12 @@ export function useCommentActions({
} }
try { try {
const body: Record<string, unknown> = { content: editText }; const uploadedImageUrls = await uploadImageFiles(editImageFiles);
// The list the comment should end up with: what the editor kept, then
// whatever was pasted into it while it was open.
const nextImageUrls = [...editImageUrls, ...uploadedImageUrls];
const body: Record<string, unknown> = { content: editText, imageUrls: nextImageUrls };
if (editTagId !== undefined) body.tagId = editTagId; if (editTagId !== undefined) body.tagId = editTagId;
if (finalAnnotationData !== undefined) { if (finalAnnotationData !== undefined) {
body.annotationData = body.annotationData =
@@ -1028,10 +1106,21 @@ export function useCommentActions({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const payload = (await res.json().catch(() => null)) as {
data?: { images?: CommentImage[] };
error?: string;
code?: string;
} | null;
if (res.ok) { if (res.ok) {
const editedTag = editTagId const editedTag = editTagId
? availableTags.find((t) => t.id === editTagId) || null ? availableTags.find((t) => t.id === editTagId) || null
: null; : null;
// The response carries the saved rows with their real ids; fall back to
// the URLs that were sent if it did not come back as JSON.
const savedImages: CommentImage[] =
payload?.data?.images ??
nextImageUrls.map((url, index) => ({ id: `${commentId}-image-${index}`, url }));
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
@@ -1045,6 +1134,7 @@ export function useCommentActions({
return { return {
...c, ...c,
content: editText.trim(), content: editText.trim(),
images: savedImages,
tag: editTagId !== undefined ? editedTag : c.tag, tag: editTagId !== undefined ? editedTag : c.tag,
annotationData: annotationData:
finalAnnotationData !== undefined finalAnnotationData !== undefined
@@ -1054,7 +1144,9 @@ export function useCommentActions({
return { return {
...c, ...c,
replies: (c.replies || []).map((r) => replies: (c.replies || []).map((r) =>
r.id === commentId ? { ...r, content: editText.trim() } : r r.id === commentId
? { ...r, content: editText.trim(), images: savedImages }
: r
), ),
}; };
}), }),
@@ -1063,11 +1155,10 @@ export function useCommentActions({
), ),
}; };
}); });
setEditingCommentId(null); cancelEditingComment();
setEditText(''); if (uploadedImageUrls.length > 0) {
setEditTagId(undefined); void fetchAssets();
setEditAnnotationData(undefined); }
setIsEditingAnnotation(false);
if (finalAnnotationData !== undefined && finalAnnotationData) { if (finalAnnotationData !== undefined && finalAnnotationData) {
try { try {
const parsed = JSON.parse(finalAnnotationData); const parsed = JSON.parse(finalAnnotationData);
@@ -1079,9 +1170,13 @@ export function useCommentActions({
} else if (finalAnnotationData === null) { } else if (finalAnnotationData === null) {
setViewingAnnotation(null); setViewingAnnotation(null);
} }
} else {
toastApiError(payload, 'Failed to save changes');
} }
} catch (err) { } catch (error) {
console.error('Failed to edit comment:', err); // An upload can fail on quota before the comment is ever touched, and
// that message is worth showing; a network fault falls back.
toastApiError(error instanceof ApiRequestError ? error : null, 'Failed to save changes');
} finally { } finally {
setIsSubmittingEdit(false); setIsSubmittingEdit(false);
isMutatingRef.current = false; isMutatingRef.current = false;
@@ -1091,12 +1186,17 @@ export function useCommentActions({
editText, editText,
editTagId, editTagId,
editAnnotationData, editAnnotationData,
editImageFiles,
editImageUrls,
uploadImageFiles,
cancelEditingComment,
isEditingAnnotation, isEditingAnnotation,
activeVersionId, activeVersionId,
availableTags, availableTags,
isGuest, isGuest,
normalizedGuestName, normalizedGuestName,
editAnnotationCanvasRef, editAnnotationCanvasRef,
fetchAssets,
setVideo, setVideo,
setViewingAnnotation, setViewingAnnotation,
] ]
@@ -1203,14 +1303,15 @@ export function useCommentActions({
recordingTime, recordingTime,
audioBlob, audioBlob,
isUploadingAudio, isUploadingAudio,
imageBlob, imageFiles,
setImageBlob, setImageFiles,
commentRangeStart, commentRangeStart,
commentRangeEnd, commentRangeEnd,
toggleCommentRangeSelection, toggleCommentRangeSelection,
clearCommentRangeSelection, clearCommentRangeSelection,
isUploadingImage, isUploadingImage,
imageInputRef, imageInputRef,
removeImageFile,
handleAddComment, handleAddComment,
handleImageSelect, handleImageSelect,
handlePaste, handlePaste,
@@ -1228,8 +1329,8 @@ export function useCommentActions({
isReplyRecording, isReplyRecording,
replyRecordingTime, replyRecordingTime,
replyAudioBlob, replyAudioBlob,
replyImageBlob, replyImageFiles,
setReplyImageBlob, setReplyImageFiles,
replyRangeStart, replyRangeStart,
replyRangeEnd, replyRangeEnd,
toggleReplyRangeSelection, toggleReplyRangeSelection,
@@ -1253,6 +1354,13 @@ export function useCommentActions({
setEditAnnotationData, setEditAnnotationData,
isEditingAnnotation, isEditingAnnotation,
setIsEditingAnnotation, setIsEditingAnnotation,
editImageUrls,
editImageFiles,
editImageInputRef,
startEditingComment,
startEditingReply,
cancelEditingComment,
removeEditImageUrl,
isSubmittingEdit, isSubmittingEdit,
handleEditComment, handleEditComment,
handleDeleteComment, handleDeleteComment,
@@ -0,0 +1,22 @@
'use client';
import { useEffect, useMemo } from 'react';
/**
* Blob URLs for a list of staged files, revoked as soon as a file leaves the list.
*
* Calling `URL.createObjectURL` inline in the markup mints a new URL on every
* render and never releases any of them, which a five-screenshot preview grid
* turns into a steady leak.
*/
export function useObjectUrls(files: File[]): string[] {
const urls = useMemo(() => files.map((file) => URL.createObjectURL(file)), [files]);
useEffect(() => {
return () => {
urls.forEach((url) => URL.revokeObjectURL(url));
};
}, [urls]);
return urls;
}
+128
View File
@@ -0,0 +1,128 @@
'use client';
import { memo } from 'react';
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useObjectUrls } from '@/components/video-page/hooks/use-object-urls';
import type { CommentImage } from '@/components/video-page/types';
interface ImageAttachmentStripProps {
/** Images already saved on the comment being edited, if any. */
existingUrls?: string[];
onRemoveExisting?: (url: string) => void;
/** Files staged in this editor and not uploaded yet. */
files: File[];
onRemoveFile: (index: number) => void;
compact?: boolean;
className?: string;
}
/**
* The row of thumbnails under an editor, showing what will be sent with it.
* Saved images come first, then the ones staged in this session.
*/
export const ImageAttachmentStrip = memo(function ImageAttachmentStrip({
existingUrls = [],
onRemoveExisting,
files,
onRemoveFile,
compact = false,
className,
}: ImageAttachmentStripProps) {
const previewUrls = useObjectUrls(files);
if (existingUrls.length === 0 && previewUrls.length === 0) return null;
const tileSize = compact ? 'h-14 w-14' : 'h-20 w-20';
const buttonSize = compact ? 'h-5 w-5' : 'h-6 w-6';
const iconSize = compact ? 'h-2.5 w-2.5' : 'h-3 w-3';
const tile = (key: string, src: string, alt: string, onRemove: () => void) => (
<div
key={key}
className={cn(
'group/attachment relative shrink-0 overflow-hidden rounded-md border bg-muted',
tileSize
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt} className="h-full w-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover/attachment:opacity-100">
<Button size="icon" variant="destructive" className={buttonSize} onClick={onRemove}>
<Trash2 className={iconSize} />
</Button>
</div>
</div>
);
return (
<div className={cn('mb-2 flex flex-wrap gap-2', className)}>
{existingUrls.map((url, index) =>
tile(url, url, `Attachment ${index + 1}`, () => onRemoveExisting?.(url))
)}
{previewUrls.map((url, index) =>
tile(`staged-${index}`, url, `Preview ${index + 1}`, () => onRemoveFile(index))
)}
</div>
);
});
interface CommentImageGalleryProps {
images: CommentImage[];
onOpen: (url: string) => void;
compact?: boolean;
className?: string;
}
/** The images saved on a comment. One fills the width; several tile into a grid. */
export const CommentImageGallery = memo(function CommentImageGallery({
images,
onOpen,
compact = false,
className,
}: CommentImageGalleryProps) {
if (images.length === 0) return null;
if (images.length === 1) {
return (
<div
className={cn(
'flex cursor-pointer items-center justify-center overflow-hidden rounded-md bg-muted transition-opacity hover:opacity-90',
compact ? 'max-h-40' : 'max-h-60',
className
)}
onClick={() => onOpen(images[0].url)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images[0].url}
alt="Attachment"
className={cn('w-auto object-contain', compact ? 'max-h-40' : 'max-h-60')}
/>
</div>
);
}
return (
<div className={cn('grid grid-cols-2 gap-1.5', className)}>
{images.map((image, index) => (
<div
key={image.id}
className={cn(
'cursor-pointer overflow-hidden rounded-md bg-muted transition-opacity hover:opacity-90',
compact ? 'h-20' : 'h-24'
)}
onClick={() => onOpen(image.url)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.url}
alt={`Attachment ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
);
});
+10 -4
View File
@@ -18,16 +18,22 @@ export async function validateImageFile(file: File): Promise<string | null> {
return null; return null;
} }
export function extractPastedImageFile(data: DataTransfer | null | undefined): File | null { /**
* Every image on the clipboard or in a drop, in the order the browser lists them.
* A screenshot batch arrives as several items in one paste, so taking only the
* first would silently drop the rest.
*/
export function extractPastedImageFiles(data: DataTransfer | null | undefined): File[] {
const items = data?.items; const items = data?.items;
if (!items) return null; if (!items) return [];
const files: File[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
if (!item.type.startsWith('image/')) continue; if (!item.type.startsWith('image/')) continue;
const file = item.getAsFile(); const file = item.getAsFile();
if (file) return file; if (file) files.push(file);
} }
return null; return files;
} }
+8 -3
View File
@@ -75,6 +75,11 @@ export interface ApprovalRequest {
decisions: ApprovalDecision[]; decisions: ApprovalDecision[];
} }
export interface CommentImage {
id: string;
url: string;
}
export interface CommentReply { export interface CommentReply {
id: string; id: string;
content: string | null; content: string | null;
@@ -82,7 +87,7 @@ export interface CommentReply {
timestampEnd: number | null; timestampEnd: number | null;
voiceUrl: string | null; voiceUrl: string | null;
voiceDuration: number | null; voiceDuration: number | null;
imageUrl: string | null; images: CommentImage[];
annotationData: string | null; annotationData: 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;
@@ -99,7 +104,7 @@ export interface Comment {
timestampEnd: number | null; timestampEnd: number | null;
voiceUrl: string | null; voiceUrl: string | null;
voiceDuration: number | null; voiceDuration: number | null;
imageUrl: string | null; images: CommentImage[];
annotationData: string | null; annotationData: string | null;
isResolved: boolean; isResolved: boolean;
createdAt: string; createdAt: string;
@@ -210,7 +215,7 @@ export interface VideoPageCommentsActions {
onReplyComment: ( onReplyComment: (
parentId: string, parentId: string,
voiceData?: { url: string; duration: number }, voiceData?: { url: string; duration: number },
imageData?: { url: string } imageUrls?: string[]
) => void; ) => void;
onSubmitReplyWithMedia: (parentId: string) => void; onSubmitReplyWithMedia: (parentId: string) => void;
onStartEditAnnotation: () => void; onStartEditAnnotation: () => void;
+4 -4
View File
@@ -390,10 +390,10 @@ export async function getCachedUserMediaStorage(): Promise<
const [mediaComments, imageAssets, audioAssets] = await Promise.all([ const [mediaComments, imageAssets, audioAssets] = await Promise.all([
db.comment.findMany({ db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] }, where: { OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }] },
select: { select: {
voiceUrl: true, voiceUrl: true,
imageUrl: true, images: { select: { url: true } },
version: { version: {
select: { select: {
video: { video: {
@@ -448,8 +448,8 @@ export async function getCachedUserMediaStorage(): Promise<
} }
} }
if (comment.imageUrl) { for (const image of comment.images) {
const keyParts = comment.imageUrl.split('/'); const keyParts = image.url.split('/');
const filename = keyParts[keyParts.length - 1]; const filename = keyParts[keyParts.length - 1];
const r2Key = `images/${filename}`; const r2Key = `images/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`; const dedupeKey = `${billedUserId}:${r2Key}`;
+53
View File
@@ -0,0 +1,53 @@
import { SAFE_IMAGE_PROXY_PATH } from '@/lib/image-upload-validation';
/**
* How many images a single comment (or reply) may carry. Pasting a batch of
* screenshots is the normal case, so the cap is there to bound the upload
* burst and the row width, not to make the feature scarce.
*/
export const MAX_COMMENT_IMAGES = 5;
export type CommentImageUrlsResult = { urls: string[] } | { error: string };
/**
* Normalize whatever a client sent for a comment's images into an ordered,
* de-duplicated list of upload URLs.
*
* Accepts the legacy single `imageUrl` alongside the `imageUrls` list so an
* older client keeps working. Returns a message rather than throwing, because
* every caller turns it straight into a 400.
*/
export function parseCommentImageUrls(input: {
imageUrl?: unknown;
imageUrls?: unknown;
}): CommentImageUrlsResult {
const { imageUrl, imageUrls } = input;
let raw: unknown[];
if (imageUrls !== undefined && imageUrls !== null) {
if (!Array.isArray(imageUrls)) {
return { error: 'imageUrls must be an array of uploaded image URLs' };
}
raw = imageUrls;
} else if (imageUrl !== undefined && imageUrl !== null) {
raw = [imageUrl];
} else {
raw = [];
}
const urls: string[] = [];
for (const value of raw) {
if (typeof value !== 'string' || !SAFE_IMAGE_PROXY_PATH.test(value)) {
return { error: 'Image URL must reference an uploaded image file' };
}
// The same file twice would trip the unique index on comment_images and
// charge the account twice for one object, so collapse it here instead.
if (!urls.includes(value)) urls.push(value);
}
if (urls.length > MAX_COMMENT_IMAGES) {
return { error: `A comment can have at most ${MAX_COMMENT_IMAGES} images` };
}
return { urls };
}
+4
View File
@@ -1,3 +1,7 @@
/** The only shape an image URL may take once it has been through our upload API. */
export const SAFE_IMAGE_PROXY_PATH =
/^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const ALLOWED_IMAGE_MIME_TYPES = [ export const ALLOWED_IMAGE_MIME_TYPES = [
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
+9 -9
View File
@@ -85,10 +85,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
const [comments, assets, versions] = await Promise.all([ const [comments, assets, versions] = await Promise.all([
db.comment.findMany({ db.comment.findMany({
where: { where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
version: { videoParentId: videoId }, version: { videoParentId: videoId },
}, },
select: { voiceUrl: true, imageUrl: true }, select: { voiceUrl: true, images: { select: { url: true } } },
}), }),
db.videoAsset.findMany({ db.videoAsset.findMany({
where: { where: {
@@ -105,7 +105,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
const urls: string[] = []; const urls: string[] = [];
comments.forEach((c) => { comments.forEach((c) => {
if (c.voiceUrl) urls.push(c.voiceUrl); if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl); c.images.forEach((image) => urls.push(image.url));
}); });
assets.forEach((asset) => { assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl); if (asset.sourceUrl) urls.push(asset.sourceUrl);
@@ -124,10 +124,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
const [comments, assets, versions] = await Promise.all([ const [comments, assets, versions] = await Promise.all([
db.comment.findMany({ db.comment.findMany({
where: { where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
version: { video: { projectId } }, version: { video: { projectId } },
}, },
select: { voiceUrl: true, imageUrl: true }, select: { voiceUrl: true, images: { select: { url: true } } },
}), }),
db.videoAsset.findMany({ db.videoAsset.findMany({
where: { where: {
@@ -144,7 +144,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
const urls: string[] = []; const urls: string[] = [];
comments.forEach((c) => { comments.forEach((c) => {
if (c.voiceUrl) urls.push(c.voiceUrl); if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl); c.images.forEach((image) => urls.push(image.url));
}); });
assets.forEach((asset) => { assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl); if (asset.sourceUrl) urls.push(asset.sourceUrl);
@@ -163,10 +163,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
const [comments, assets, versions] = await Promise.all([ const [comments, assets, versions] = await Promise.all([
db.comment.findMany({ db.comment.findMany({
where: { where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
version: { video: { project: { workspaceId } } }, version: { video: { project: { workspaceId } } },
}, },
select: { voiceUrl: true, imageUrl: true }, select: { voiceUrl: true, images: { select: { url: true } } },
}), }),
db.videoAsset.findMany({ db.videoAsset.findMany({
where: { where: {
@@ -183,7 +183,7 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
const urls: string[] = []; const urls: string[] = [];
comments.forEach((c) => { comments.forEach((c) => {
if (c.voiceUrl) urls.push(c.voiceUrl); if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl); c.images.forEach((image) => urls.push(image.url));
}); });
assets.forEach((asset) => { assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl); if (asset.sourceUrl) urls.push(asset.sourceUrl);
+40
View File
@@ -0,0 +1,40 @@
import { HeadObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
/**
* How long an uploaded file may sit in R2 before the row that would claim it has
* to exist. Anything older is treated as an expired upload, so a URL cannot be
* replayed later to attach a file the caller no longer has a right to.
*/
export const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
export type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
/**
* Confirm an upload URL points at an object that was written just now, and
* report its size so the caller can bill it against a quota.
*/
export async function isFreshAttachment(
url: string,
kind: 'audio' | 'image'
): Promise<AttachmentCheck> {
const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/';
if (!url.startsWith(prefix)) return { isFresh: false, sizeBytes: BigInt(0) };
const filename = url.slice(prefix.length);
const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`;
try {
const head = await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
} catch {
return { isFresh: false, sizeBytes: BigInt(0) };
}
}
+3 -2
View File
@@ -6,13 +6,14 @@ import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
import { getShareSessionFromRequest } from '@/lib/share-session'; import { getShareSessionFromRequest } from '@/lib/share-session';
import { validateShareLinkAccess } from '@/lib/share-links'; import { validateShareLinkAccess } from '@/lib/share-links';
import { canDownloadProjectMedia } from '@/lib/project-download'; import { canDownloadProjectMedia } from '@/lib/project-download';
import { SAFE_IMAGE_PROXY_PATH } from '@/lib/image-upload-validation';
export { SAFE_IMAGE_PROXY_PATH };
const IMAGE_PROXY_PREFIX = '/api/upload/image/'; const IMAGE_PROXY_PREFIX = '/api/upload/image/';
const AUDIO_PROXY_PREFIX = '/api/upload/audio/'; const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
const VIDEO_PROXY_PREFIX = '/api/upload/video/'; const VIDEO_PROXY_PREFIX = '/api/upload/video/';
export const SAFE_IMAGE_PROXY_PATH =
/^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const SAFE_AUDIO_PROXY_PATH = export const SAFE_AUDIO_PROXY_PATH =
/^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const SAFE_VIDEO_PROXY_PATH = export const SAFE_VIDEO_PROXY_PATH =
@@ -0,0 +1,28 @@
-- A comment used to hold at most one image, in "comments"."imageUrl". Screenshots
-- arrive in batches, so the images move into their own table and the old column
-- stays as a pointer to the first one for readers that have not been updated.
CREATE TABLE "comment_images" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"commentId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "comment_images_pkey" PRIMARY KEY ("id")
);
-- An uploaded file belongs to exactly one comment, which is what the old
-- "comments_imageUrl_key" guaranteed. Reference checks before an R2 delete
-- rely on it.
CREATE UNIQUE INDEX "comment_images_url_key" ON "comment_images"("url");
CREATE INDEX "comment_images_commentId_position_idx" ON "comment_images"("commentId", "position");
ALTER TABLE "comment_images" ADD CONSTRAINT "comment_images_commentId_fkey"
FOREIGN KEY ("commentId") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Existing single attachments become the first image of their comment, so the
-- new table is the complete list from the first read after this migration.
INSERT INTO "comment_images" ("id", "url", "position", "commentId", "createdAt")
SELECT gen_random_uuid()::text, "imageUrl", 0, "id", "createdAt"
FROM "comments"
WHERE "imageUrl" IS NOT NULL;
+17 -1
View File
@@ -432,8 +432,10 @@ 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) // Image attachment (optional). `imageUrl` is the first image and stays for
// backwards compatibility; `images` is the full, ordered list.
imageUrl String? // URL to uploaded image file imageUrl String? // URL to uploaded image file
images CommentImage[]
// Annotation drawing data (JSON string of strokes) // Annotation drawing data (JSON string of strokes)
annotationData String? @db.Text annotationData String? @db.Text
@@ -481,6 +483,20 @@ model Comment {
@@map("comments") @@map("comments")
} }
model CommentImage {
id String @id @default(cuid())
url String @unique
position Int @default(0)
commentId String
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([commentId, position])
@@map("comment_images")
}
model CommentTag { model CommentTag {
id String @id @default(cuid()) id String @id @default(cuid())
name String // e.g., "Feedback", "Technical", "Urgent" name String // e.g., "Feedback", "Technical", "Urgent"
+17 -10
View File
@@ -146,16 +146,21 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
).userFeedbackScreenshot; ).userFeedbackScreenshot;
for (const group of chunk(urls, CHUNK_SIZE)) { for (const group of chunk(urls, CHUNK_SIZE)) {
const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows, versionRows] = const [
await Promise.all([ commentRows,
commentImageRows,
feedbackRows,
feedbackAttachmentRows,
assetRows,
versionRows,
] = await Promise.all([
db.comment.findMany({ db.comment.findMany({
where: { where: { voiceUrl: { in: group } },
OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }], select: { voiceUrl: true },
}, }),
select: { db.commentImage.findMany({
voiceUrl: true, where: { url: { in: group } },
imageUrl: true, select: { url: true },
},
}), }),
db.userFeedback.findMany({ db.userFeedback.findMany({
where: { screenshotUrl: { in: group } }, where: { screenshotUrl: { in: group } },
@@ -183,7 +188,9 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
for (const row of commentRows) { for (const row of commentRows) {
if (row.voiceUrl) referenced.add(row.voiceUrl); if (row.voiceUrl) referenced.add(row.voiceUrl);
if (row.imageUrl) referenced.add(row.imageUrl); }
for (const row of commentImageRows) {
if (row.url) referenced.add(row.url);
} }
for (const row of feedbackRows) { for (const row of feedbackRows) {
if (row.screenshotUrl) referenced.add(row.screenshotUrl); if (row.screenshotUrl) referenced.add(row.screenshotUrl);
+285
View File
@@ -11,6 +11,7 @@ import {
GET as getCommentRoute, GET as getCommentRoute,
PATCH as patchCommentRoute, PATCH as patchCommentRoute,
} from '@/app/api/comments/[commentId]/route'; } from '@/app/api/comments/[commentId]/route';
import { isFreshAttachment } from '@/lib/upload-freshness';
import { apiRequest, callRoute, readData, readError } from '../helpers/request'; import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session'; import { signedInAs, signedOut } from '../helpers/session';
import { import {
@@ -28,6 +29,32 @@ import {
seedVersion, seedVersion,
} from '../factories'; } from '../factories';
// The real check heads the object in R2. Standing in for it lets these tests
// drive the attachment paths; a suite that wants a stale upload overrides it.
vi.mock('@/lib/upload-freshness', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/upload-freshness')>();
return {
...actual,
isFreshAttachment: vi.fn(async () => ({ isFresh: true, sizeBytes: BigInt(1024) })),
};
});
const IMAGE_A = '/api/upload/image/11111111-2222-3333-4444-555555555555.png';
const IMAGE_B = '/api/upload/image/66666666-7777-8888-9999-aaaaaaaaaaaa.png';
const IMAGE_C = '/api/upload/image/bbbbbbbb-cccc-dddd-eeee-ffffffffffff.png';
const IMAGE_D = '/api/upload/image/12121212-3434-5656-7878-909090909090.png';
const IMAGE_E = '/api/upload/image/abababab-cdcd-efef-0101-232323232323.png';
const IMAGE_F = '/api/upload/image/45454545-6767-8989-0a0a-1b1b1b1b1b1b.png';
async function imageUrlsOf(commentId: string): Promise<string[]> {
const images = await db.commentImage.findMany({
where: { commentId },
orderBy: { position: 'asc' },
select: { url: true },
});
return images.map((image) => image.url);
}
const VALID_STROKE = { const VALID_STROKE = {
points: [ points: [
{ x: 0.1, y: 0.2 }, { x: 0.1, y: 0.2 },
@@ -729,6 +756,264 @@ describe('POST /api/versions/[versionId]/comments', () => {
}); });
}); });
// A comment used to carry a single image. Screenshots arrive in batches, so the
// list is the contract now and the old `imageUrl` column follows its first entry.
describe('comment image attachments', () => {
it('stores every image in order, points imageUrl at the first, and lists them all as assets', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
createCommentRoute,
apiRequest(commentsUrl(scenario.version.id), {
body: { content: 'three shots', timestamp: 1, imageUrls: [IMAGE_A, IMAGE_B, IMAGE_C] },
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(201);
const created = await readData<{ id: string; images: { url: string }[] }>(response);
expect(created.images.map((image) => image.url)).toEqual([IMAGE_A, IMAGE_B, IMAGE_C]);
const stored = await db.comment.findUniqueOrThrow({ where: { id: created.id } });
expect(stored.imageUrl).toBe(IMAGE_A);
expect(await imageUrlsOf(created.id)).toEqual([IMAGE_A, IMAGE_B, IMAGE_C]);
const assets = await db.videoAsset.findMany({
where: { videoId: scenario.video.id, provider: 'R2_IMAGE' },
select: { sourceUrl: true },
});
expect(assets.map((asset) => asset.sourceUrl).sort()).toEqual(
[IMAGE_A, IMAGE_B, IMAGE_C].sort()
);
});
it('still accepts the legacy single imageUrl', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
createCommentRoute,
apiRequest(commentsUrl(scenario.version.id), {
body: { content: 'one shot', timestamp: 1, imageUrl: IMAGE_A },
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(201);
const created = await readData<{ id: string }>(response);
expect(await imageUrlsOf(created.id)).toEqual([IMAGE_A]);
});
it('accepts a comment that is nothing but images', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
createCommentRoute,
apiRequest(commentsUrl(scenario.version.id), {
body: { timestamp: 1, imageUrls: [IMAGE_A] },
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(201);
});
it('refuses more images than a comment may hold, and writes nothing', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
createCommentRoute,
apiRequest(commentsUrl(scenario.version.id), {
body: {
content: 'too many',
timestamp: 1,
imageUrls: [IMAGE_A, IMAGE_B, IMAGE_C, IMAGE_D, IMAGE_E, IMAGE_F],
},
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toBe('A comment can have at most 5 images');
expect(await db.comment.count()).toBe(0);
expect(await db.commentImage.count()).toBe(0);
});
it('refuses the whole comment when one of the uploads has expired', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
vi.mocked(isFreshAttachment).mockImplementation(async (url: string) => ({
isFresh: url !== IMAGE_B,
sizeBytes: BigInt(1024),
}));
const response = await callRoute(
createCommentRoute,
apiRequest(commentsUrl(scenario.version.id), {
body: { content: 'stale', timestamp: 1, imageUrls: [IMAGE_A, IMAGE_B] },
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(400);
expect(await db.comment.count()).toBe(0);
expect(await db.commentImage.count()).toBe(0);
vi.mocked(isFreshAttachment).mockResolvedValue({ isFresh: true, sizeBytes: BigInt(1024) });
});
it('replaces the list on edit: keeps one, drops one, adds one', async () => {
const scenario = await seedVersion();
const comment = await createComment({
versionId: scenario.version.id,
authorId: scenario.owner.id,
imageUrls: [IMAGE_A, IMAGE_B],
});
signedInAs(scenario.owner);
const response = await callRoute(
patchCommentRoute,
apiRequest(`/api/comments/${comment.id}`, {
method: 'PATCH',
body: { content: 'reworded', imageUrls: [IMAGE_B, IMAGE_C] },
}),
{ commentId: comment.id }
);
expect(response.status).toBe(200);
expect(await imageUrlsOf(comment.id)).toEqual([IMAGE_B, IMAGE_C]);
// The legacy column follows the new first image.
expect((await db.comment.findUniqueOrThrow({ where: { id: comment.id } })).imageUrl).toBe(
IMAGE_B
);
// The image added while editing shows up in the assets pane like any other.
expect(await db.videoAsset.count({ where: { sourceUrl: IMAGE_C } })).toBe(1);
// The detached file is not deleted here: the assets pane owns its lifetime.
expect(await db.commentImage.count({ where: { url: IMAGE_A } })).toBe(0);
});
it('clears every image when the edit sends an empty list', async () => {
const scenario = await seedVersion();
const comment = await createComment({
versionId: scenario.version.id,
authorId: scenario.owner.id,
imageUrls: [IMAGE_A, IMAGE_B],
});
signedInAs(scenario.owner);
const response = await callRoute(
patchCommentRoute,
apiRequest(`/api/comments/${comment.id}`, {
method: 'PATCH',
body: { content: 'text only now', imageUrls: [] },
}),
{ commentId: comment.id }
);
expect(response.status).toBe(200);
expect(await imageUrlsOf(comment.id)).toEqual([]);
expect((await db.comment.findUniqueOrThrow({ where: { id: comment.id } })).imageUrl).toBeNull();
});
it('leaves the images alone when the edit does not mention them', async () => {
const scenario = await seedVersion();
const comment = await createComment({
versionId: scenario.version.id,
authorId: scenario.owner.id,
imageUrls: [IMAGE_A],
});
signedInAs(scenario.owner);
const response = await callRoute(
patchCommentRoute,
apiRequest(`/api/comments/${comment.id}`, {
method: 'PATCH',
body: { content: 'only the words changed' },
}),
{ commentId: comment.id }
);
expect(response.status).toBe(200);
expect(await imageUrlsOf(comment.id)).toEqual([IMAGE_A]);
});
it('returns 403 when somebody other than the author changes the images', async () => {
const scenario = await seedVersion();
const author = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: author.id });
const comment = await createComment({
versionId: scenario.version.id,
authorId: author.id,
imageUrls: [IMAGE_A],
});
signedInAs(scenario.owner);
const response = await callRoute(
patchCommentRoute,
apiRequest(`/api/comments/${comment.id}`, {
method: 'PATCH',
body: { imageUrls: [IMAGE_A, IMAGE_B] },
}),
{ commentId: comment.id }
);
expect(response.status).toBe(403);
expect(await imageUrlsOf(comment.id)).toEqual([IMAGE_A]);
});
it('refuses to steal an image that already hangs off another comment', async () => {
const scenario = await seedVersion();
const other = await createComment({
versionId: scenario.version.id,
authorId: scenario.owner.id,
imageUrls: [IMAGE_A],
});
const comment = await createComment({
versionId: scenario.version.id,
authorId: scenario.owner.id,
imageUrls: [IMAGE_B],
});
signedInAs(scenario.owner);
const response = await callRoute(
patchCommentRoute,
apiRequest(`/api/comments/${comment.id}`, {
method: 'PATCH',
body: { imageUrls: [IMAGE_B, IMAGE_A] },
}),
{ commentId: comment.id }
);
expect(response.status).toBe(400);
expect(await imageUrlsOf(comment.id)).toEqual([IMAGE_B]);
expect(await imageUrlsOf(other.id)).toEqual([IMAGE_A]);
});
it('refuses an edit that would carry more images than the cap', async () => {
const scenario = await seedVersion();
const comment = await createComment({
versionId: scenario.version.id,
authorId: scenario.owner.id,
imageUrls: [IMAGE_A],
});
signedInAs(scenario.owner);
const response = await callRoute(
patchCommentRoute,
apiRequest(`/api/comments/${comment.id}`, {
method: 'PATCH',
body: { imageUrls: [IMAGE_A, IMAGE_B, IMAGE_C, IMAGE_D, IMAGE_E, IMAGE_F] },
}),
{ commentId: comment.id }
);
expect(response.status).toBe(400);
expect(await imageUrlsOf(comment.id)).toEqual([IMAGE_A]);
});
});
describe('GET /api/comments/[commentId]', () => { describe('GET /api/comments/[commentId]', () => {
it('returns 403 to a stranger and never exposes the project row', async () => { it('returns 403 to a stranger and never exposes the project row', async () => {
const scenario = await seedVersion(); const scenario = await seedVersion();
+20
View File
@@ -270,6 +270,26 @@ describe('collectVideoMediaUrls', () => {
); );
}); });
// A comment can carry several screenshots; collecting only the first would
// leave the rest behind in R2 after the video is gone.
it('collects every image on a comment, not only the first', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
const version = await createVersion({
videoParentId: video.id,
providerId: 'r2',
originalUrl: OWN_VERSION_VIDEO,
});
await createComment({
versionId: version.id,
imageUrls: [OWN_COMMENT_IMAGE, OWN_ASSET_IMAGE],
});
const urls = await collectVideoMediaUrls(video.id);
expect(new Set(urls)).toEqual(new Set([OWN_VERSION_VIDEO, OWN_COMMENT_IMAGE, OWN_ASSET_IMAGE]));
});
// A youtube or bunny version's originalUrl is not an object this deployment // A youtube or bunny version's originalUrl is not an object this deployment
// owns, and a BUNNY asset is cleaned up through the Bunny API instead. // owns, and a BUNNY asset is cleaned up through the Bunny API instead.
it('ignores versions from other providers and assets that are not R2 images', async () => { it('ignores versions from other providers and assets that are not R2 images', async () => {
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useState, type ChangeEvent } from 'react'; import { useState, type ChangeEvent, type ClipboardEvent } from 'react';
import { act, renderHook, type RenderHookResult } from '@testing-library/react'; import { act, renderHook, type RenderHookResult } from '@testing-library/react';
import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions'; import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
import type { Comment, CommentTag, VideoData } from '@/components/video-page/types'; import type { Comment, CommentTag, VideoData } from '@/components/video-page/types';
@@ -31,7 +31,7 @@ function makeComment(overrides: Partial<Comment> = {}): Comment {
timestampEnd: null, timestampEnd: null,
voiceUrl: null, voiceUrl: null,
voiceDuration: null, voiceDuration: null,
imageUrl: null, images: [],
annotationData: null, annotationData: null,
isResolved: false, isResolved: false,
createdAt: '2026-01-01T00:00:00.000Z', createdAt: '2026-01-01T00:00:00.000Z',
@@ -79,7 +79,7 @@ function makeVideo(): VideoData {
timestampEnd: null, timestampEnd: null,
voiceUrl: null, voiceUrl: null,
voiceDuration: null, voiceDuration: null,
imageUrl: null, images: [],
annotationData: null, annotationData: null,
createdAt: '2026-01-01T00:01:00.000Z', createdAt: '2026-01-01T00:01:00.000Z',
author: { id: 'user2', name: 'Linus', image: null }, author: { id: 'user2', name: 'Linus', image: null },
@@ -495,7 +495,7 @@ describe('useCommentActions replying', () => {
timestampEnd: null, timestampEnd: null,
voiceUrl: null, voiceUrl: null,
voiceDuration: null, voiceDuration: null,
imageUrl: null, images: [],
annotationData: null, annotationData: null,
createdAt: '2026-01-02T00:00:00.000Z', createdAt: '2026-01-02T00:00:00.000Z',
author: { id: 'user1', name: 'Ada', image: null }, author: { id: 'user1', name: 'Ada', image: null },
@@ -815,6 +815,7 @@ describe('useCommentActions editing', () => {
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({ expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({
content: 'Reworded note', content: 'Reworded note',
imageUrls: [],
}); });
expect(findComment(harness, 'c1')?.tag).toEqual(TAGS[0]); expect(findComment(harness, 'c1')?.tag).toEqual(TAGS[0]);
}); });
@@ -832,6 +833,7 @@ describe('useCommentActions editing', () => {
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({ expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({
content: 'Reworded note', content: 'Reworded note',
imageUrls: [],
tagId: null, tagId: null,
}); });
expect(findComment(harness, 'c1')?.tag).toBeNull(); expect(findComment(harness, 'c1')?.tag).toBeNull();
@@ -853,6 +855,196 @@ describe('useCommentActions editing', () => {
}); });
}); });
// A screenshot batch arrives as several clipboard items in one paste, and the
// composer used to keep only the first of them.
describe('useCommentActions image attachments', () => {
// A one-pixel PNG header is enough: the client only sniffs the magic bytes.
function pngFile(name: string): File {
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], name, {
type: 'image/png',
});
}
function pasteOf(files: File[]) {
return {
clipboardData: {
items: files.map((file) => ({ type: file.type, getAsFile: () => file })),
},
preventDefault: vi.fn(),
} as unknown as ClipboardEvent<HTMLTextAreaElement>;
}
beforeEach(() => {
let uploaded = 0;
fetchMock.mockImplementation((url: string) => {
if (url === '/api/upload/image') {
uploaded += 1;
return Promise.resolve(ok({ data: { url: `/api/upload/image/shot-${uploaded}.png` } }));
}
if (url === `/api/versions/${ACTIVE_VERSION}/comments`) {
return Promise.resolve(ok({ data: serverComment }));
}
return Promise.resolve(ok({ data: {} }));
});
});
it('stages every image in a single paste', async () => {
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handlePaste(
pasteOf([pngFile('a.png'), pngFile('b.png'), pngFile('c.png')])
);
});
expect(harness.result.current.actions.imageFiles.map((file) => file.name)).toEqual([
'a.png',
'b.png',
'c.png',
]);
});
it('stops at the cap and says so', async () => {
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handlePaste(
pasteOf(['a', 'b', 'c', 'd', 'e', 'f'].map((name) => pngFile(`${name}.png`)))
);
});
expect(harness.result.current.actions.imageFiles).toHaveLength(5);
expect(toastError).toHaveBeenCalledWith('Only 5 more images fit on this comment');
});
it('uploads each staged image and posts the whole list', async () => {
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handlePaste(
pasteOf([pngFile('a.png'), pngFile('b.png')])
);
});
act(() => harness.result.current.actions.setCommentText('Two shots'));
await act(async () => {
await harness.result.current.actions.handleAddComment();
});
expect(callsTo('/api/upload/image', 'POST')).toHaveLength(2);
expect(bodyOf(callsTo(`/api/versions/${ACTIVE_VERSION}/comments`, 'POST')[0])).toEqual({
content: 'Two shots',
timestamp: 12,
imageUrls: ['/api/upload/image/shot-1.png', '/api/upload/image/shot-2.png'],
});
expect(harness.result.current.actions.imageFiles).toEqual([]);
});
it('sends the images a reply was pasted into', async () => {
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handlePaste(
pasteOf([pngFile('a.png'), pngFile('b.png')]),
'reply'
);
});
act(() => harness.result.current.actions.setReplyText('Same here'));
await act(async () => {
await harness.result.current.actions.handleReplyComment('c1');
});
const body = bodyOf(callsTo(`/api/versions/${ACTIVE_VERSION}/comments`, 'POST')[0]);
expect(body.parentId).toBe('c1');
expect(body.imageUrls).toEqual([
'/api/upload/image/shot-1.png',
'/api/upload/image/shot-2.png',
]);
// The composer's own staging must not have been touched by a reply paste.
expect(harness.result.current.actions.imageFiles).toEqual([]);
});
it('seeds the editor from the comment and saves only the images left on it', async () => {
const harness = renderActions();
const existing = makeComment({
id: 'c1',
images: [
{ id: 'i1', url: '/api/upload/image/kept.png' },
{ id: 'i2', url: '/api/upload/image/dropped.png' },
],
});
act(() => harness.result.current.actions.startEditingComment(existing));
expect(harness.result.current.actions.editImageUrls).toEqual([
'/api/upload/image/kept.png',
'/api/upload/image/dropped.png',
]);
act(() => harness.result.current.actions.removeEditImageUrl('/api/upload/image/dropped.png'));
await act(async () => {
await harness.result.current.actions.handleEditComment('c1');
});
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0]).imageUrls).toEqual([
'/api/upload/image/kept.png',
]);
expect(findComment(harness, 'c1')?.images.map((image) => image.url)).toEqual([
'/api/upload/image/kept.png',
]);
});
it('uploads an image pasted into an open editor and appends it to the comment', async () => {
const harness = renderActions();
const existing = makeComment({
id: 'c1',
images: [{ id: 'i1', url: '/api/upload/image/kept.png' }],
});
act(() => harness.result.current.actions.startEditingComment(existing));
await act(async () => {
await harness.result.current.actions.handlePaste(pasteOf([pngFile('new.png')]), 'edit');
});
expect(harness.result.current.actions.editImageFiles).toHaveLength(1);
await act(async () => {
await harness.result.current.actions.handleEditComment('c1');
});
expect(callsTo('/api/upload/image', 'POST')).toHaveLength(1);
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0]).imageUrls).toEqual([
'/api/upload/image/kept.png',
'/api/upload/image/shot-1.png',
]);
// The editor closes on a successful save, so its staging has to be empty.
expect(harness.result.current.actions.editImageFiles).toEqual([]);
expect(harness.result.current.actions.editingCommentId).toBeNull();
});
it('counts the images already on the comment against the cap', async () => {
const harness = renderActions();
const existing = makeComment({
id: 'c1',
images: [
{ id: 'i1', url: '/api/upload/image/one.png' },
{ id: 'i2', url: '/api/upload/image/two.png' },
{ id: 'i3', url: '/api/upload/image/three.png' },
{ id: 'i4', url: '/api/upload/image/four.png' },
],
});
act(() => harness.result.current.actions.startEditingComment(existing));
await act(async () => {
await harness.result.current.actions.handlePaste(
pasteOf([pngFile('a.png'), pngFile('b.png'), pngFile('c.png')]),
'edit'
);
});
expect(harness.result.current.actions.editImageFiles).toHaveLength(1);
expect(toastError).toHaveBeenCalledWith('Only 1 more image fits on this comment');
});
});
describe('useCommentActions background refresh', () => { describe('useCommentActions background refresh', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -42,7 +42,7 @@ function makeComment(overrides: Partial<Comment> = {}): Comment {
timestampEnd: null, timestampEnd: null,
voiceUrl: null, voiceUrl: null,
voiceDuration: null, voiceDuration: null,
imageUrl: null, images: [],
annotationData: null, annotationData: null,
isResolved: false, isResolved: false,
createdAt: '2026-01-01T00:00:00.000Z', createdAt: '2026-01-01T00:00:00.000Z',
@@ -325,7 +325,7 @@ describe('useVideoPageData loading comments', () => {
timestampEnd: null, timestampEnd: null,
voiceUrl: null, voiceUrl: null,
voiceDuration: null, voiceDuration: null,
imageUrl: null, images: [],
annotationData: null, annotationData: null,
createdAt: '2026-01-01T00:01:00.000Z', createdAt: '2026-01-01T00:01:00.000Z',
author: { id: 'user2', name: 'Linus', image: null }, author: { id: 'user2', name: 'Linus', image: null },
+5 -1
View File
@@ -15,6 +15,7 @@ export interface CreateCommentInput {
tagId?: string | null; tagId?: string | null;
annotationData?: string | null; annotationData?: string | null;
imageUrl?: string | null; imageUrl?: string | null;
imageUrls?: string[];
voiceUrl?: string | null; voiceUrl?: string | null;
voiceDuration?: number | null; voiceDuration?: number | null;
isResolved?: boolean; isResolved?: boolean;
@@ -23,6 +24,8 @@ export interface CreateCommentInput {
export async function createComment(input: CreateCommentInput): Promise<Comment> { export async function createComment(input: CreateCommentInput): Promise<Comment> {
const seq = nextSeq(); const seq = nextSeq();
// A comment's images live in their own table; `imageUrl` is the first of them.
const imageUrls = input.imageUrls ?? (input.imageUrl ? [input.imageUrl] : []);
return db.comment.create({ return db.comment.create({
data: { data: {
versionId: input.versionId, versionId: input.versionId,
@@ -36,7 +39,8 @@ export async function createComment(input: CreateCommentInput): Promise<Comment>
parentId: input.parentId ?? null, parentId: input.parentId ?? null,
tagId: input.tagId ?? null, tagId: input.tagId ?? null,
annotationData: input.annotationData ?? null, annotationData: input.annotationData ?? null,
imageUrl: input.imageUrl ?? null, imageUrl: imageUrls[0] ?? null,
images: { create: imageUrls.map((url, index) => ({ url, position: index })) },
voiceUrl: input.voiceUrl ?? null, voiceUrl: input.voiceUrl ?? null,
voiceDuration: input.voiceDuration ?? null, voiceDuration: input.voiceDuration ?? null,
isResolved: input.isResolved ?? false, isResolved: input.isResolved ?? false,
+1
View File
@@ -68,6 +68,7 @@ const REVIEWED_MIGRATIONS = [
'20260627140000_add_video_upload_multipart_id', '20260627140000_add_video_upload_multipart_id',
'20260801120000_add_acquisition_analytics', '20260801120000_add_acquisition_analytics',
'20260818120000_add_upload_reservation_purpose', '20260818120000_add_upload_reservation_purpose',
'20260820120000_add_comment_images',
]; ];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */ /** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { parseCommentImageUrls } from '@/lib/comment-images';
const A = '/api/upload/image/11111111-2222-3333-4444-555555555555.png';
const B = '/api/upload/image/66666666-7777-8888-9999-aaaaaaaaaaaa.jpg';
const C = '/api/upload/image/bbbbbbbb-cccc-dddd-eeee-ffffffffffff.webp';
const D = '/api/upload/image/12121212-3434-5656-7878-909090909090.gif';
const E = '/api/upload/image/abababab-cdcd-efef-0101-232323232323.png';
const F = '/api/upload/image/45454545-6767-8989-0a0a-1b1b1b1b1b1b.png';
describe('parseCommentImageUrls', () => {
it('reads an ordered list', () => {
expect(parseCommentImageUrls({ imageUrls: [A, B] })).toEqual({ urls: [A, B] });
});
it('treats a comment with no images as an empty list', () => {
expect(parseCommentImageUrls({})).toEqual({ urls: [] });
expect(parseCommentImageUrls({ imageUrls: [] })).toEqual({ urls: [] });
});
it('accepts the legacy single imageUrl as a one-element list', () => {
expect(parseCommentImageUrls({ imageUrl: A })).toEqual({ urls: [A] });
});
it('ignores imageUrl once imageUrls is given, so the list wins', () => {
expect(parseCommentImageUrls({ imageUrl: A, imageUrls: [B] })).toEqual({ urls: [B] });
});
it('collapses a URL repeated in one request', () => {
expect(parseCommentImageUrls({ imageUrls: [A, B, A] })).toEqual({ urls: [A, B] });
});
it('allows exactly five images and refuses a sixth', () => {
expect(parseCommentImageUrls({ imageUrls: [A, B, C, D, E] })).toEqual({
urls: [A, B, C, D, E],
});
expect(parseCommentImageUrls({ imageUrls: [A, B, C, D, E, F] })).toEqual({
error: 'A comment can have at most 5 images',
});
});
it('counts the cap after de-duplication', () => {
expect(parseCommentImageUrls({ imageUrls: [A, A, B, C, D, E] })).toEqual({
urls: [A, B, C, D, E],
});
});
it.each([
['a URL outside the upload API', 'https://evil.example.com/shot.png'],
['a path traversal', '/api/upload/image/../../etc/passwd'],
['an audio upload', '/api/upload/audio/11111111-2222-3333-4444-555555555555.webm'],
['a filename that is not a uuid', '/api/upload/image/shot.png'],
])('refuses %s', (_label, url) => {
expect(parseCommentImageUrls({ imageUrls: [url] })).toEqual({
error: 'Image URL must reference an uploaded image file',
});
});
it('refuses a non-string entry', () => {
expect(parseCommentImageUrls({ imageUrls: [A, 42] })).toEqual({
error: 'Image URL must reference an uploaded image file',
});
});
it('refuses imageUrls that is not an array', () => {
expect(parseCommentImageUrls({ imageUrls: A })).toEqual({
error: 'imageUrls must be an array of uploaded image URLs',
});
});
it('refuses a legacy imageUrl that is not a valid upload URL', () => {
expect(parseCommentImageUrls({ imageUrl: 'https://evil.example.com/shot.png' })).toEqual({
error: 'Image URL must reference an uploaded image file',
});
});
});