mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(comments): carry a batch of screenshots on one comment
A comment held one image, and the paste handler took the first item off the clipboard and dropped the rest. Reviewing a cut usually means several screenshots about the same moment, which meant one comment per screenshot or one screenshot and a paragraph describing the others. Editing a comment could not attach anything at all: the edit box had no paste handler, no file picker and no way to remove what was already there. A comment now carries up to five images, in the composer, in a reply and in the editor. One paste stages every image on the clipboard, the file picker takes a multiple selection, and a drop lands on whichever editor is open. Over the cap the extras are refused out loud rather than dropped quietly. A single image still fills the width; several tile into a grid, and either opens full screen on click. The images move into their own table. `comments.imageUrl` stays and follows the first of them, so a reader that has not been updated keeps working, and the migration copies the existing attachments across so the new table is complete from the first read. Every path that resolves a URL back to a comment now asks the new table: R2 cleanup, the orphan sweep, the storage accounting and the reference checks that decide whether an object can be deleted. Left on the old column they would have treated images two through five as unreferenced and swept them. Detaching an image while editing only breaks the link. The file stays in R2 and in the assets pane, which is where it is deleted from and where its bytes are already billed.
This commit is contained in:
+1
-1
@@ -82,7 +82,7 @@ export default async function AdminDashboardPage() {
|
||||
where: { voiceUrl: { not: null } },
|
||||
}),
|
||||
db.comment.count({
|
||||
where: { imageUrl: { not: null } },
|
||||
where: { images: { some: {} } },
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -105,8 +105,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] =
|
||||
await Promise.all([
|
||||
db.comment.findFirst({
|
||||
where: { imageUrl: url },
|
||||
db.commentImage.findFirst({
|
||||
where: { url },
|
||||
select: { id: true },
|
||||
}),
|
||||
userFeedbackDelegate.findFirst({
|
||||
|
||||
@@ -10,6 +10,14 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
import { runWithConcurrency } from '@/lib/async-pool';
|
||||
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';
|
||||
|
||||
const CLEANUP_DELETE_CONCURRENCY = 5;
|
||||
@@ -36,6 +44,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
@@ -57,6 +66,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
@@ -103,6 +113,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
// PATCH /api/comments/[commentId]
|
||||
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 {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
@@ -115,11 +129,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
include: {
|
||||
images: { select: { url: true }, orderBy: { position: 'asc' } },
|
||||
version: {
|
||||
include: {
|
||||
video: {
|
||||
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 (
|
||||
(content !== undefined || tagId !== undefined || annotationData !== undefined) &&
|
||||
(content !== undefined ||
|
||||
tagId !== undefined ||
|
||||
annotationData !== undefined ||
|
||||
wantsImageUpdate) &&
|
||||
!canEditOwnContent
|
||||
) {
|
||||
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
|
||||
if (isResolved !== undefined && !canResolveComment) {
|
||||
return apiErrors.forbidden('Only admins can resolve comments');
|
||||
@@ -214,21 +284,80 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
updateData.isResolved = isResolved;
|
||||
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 },
|
||||
data: updateData,
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
replies: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const updatedCommentData = Object.fromEntries(
|
||||
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');
|
||||
} catch (error) {
|
||||
await releaseStorageReservation(
|
||||
attachmentReservationId,
|
||||
attachmentBilledUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
|
||||
);
|
||||
logError('Error updating comment:', error);
|
||||
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)
|
||||
const mediaUrls: string[] = [];
|
||||
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) {
|
||||
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 } });
|
||||
|
||||
@@ -50,6 +50,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -75,6 +76,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: {
|
||||
select: { id: true, url: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
|
||||
@@ -6,8 +6,6 @@ import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import {
|
||||
ensureGuestIdentityFromRequest,
|
||||
getGuestIdentityFromRequest,
|
||||
@@ -20,6 +18,8 @@ import {
|
||||
sanitizeAssetDisplayName,
|
||||
} from '@/lib/video-assets';
|
||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||
import { parseCommentImageUrls } from '@/lib/comment-images';
|
||||
import { isFreshAttachment } from '@/lib/upload-freshness';
|
||||
import { logError } from '@/lib/logger';
|
||||
import {
|
||||
reserveStorageQuota,
|
||||
@@ -29,35 +29,8 @@ import {
|
||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
||||
|
||||
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 =
|
||||
/^\/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 {
|
||||
return value.trim().replace(/^W\//, '');
|
||||
@@ -153,6 +126,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -175,6 +149,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -275,10 +250,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
guestName,
|
||||
guestEmail,
|
||||
tagId,
|
||||
imageUrl,
|
||||
annotationData,
|
||||
} = 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
|
||||
if (timestamp === undefined || timestamp === null) {
|
||||
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(
|
||||
'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;
|
||||
}
|
||||
|
||||
if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
|
||||
return apiErrors.badRequest('Image URL must reference an uploaded image file');
|
||||
}
|
||||
let imageSizeBytes = BigInt(0);
|
||||
if (imageUrl) {
|
||||
const imageCheck = await isFreshAttachment(imageUrl, 'image');
|
||||
if (!imageCheck.isFresh) {
|
||||
// The uploads happened in parallel, so check them the same way rather than
|
||||
// paying one R2 round trip per screenshot.
|
||||
const imageChecks = await Promise.all(
|
||||
attachedImageUrls.map(async (url) => ({ url, ...(await isFreshAttachment(url, 'image')) }))
|
||||
);
|
||||
if (imageChecks.some((check) => !check.isFresh)) {
|
||||
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;
|
||||
|
||||
@@ -451,7 +432,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
parentId: parentId || null,
|
||||
voiceUrl: voiceUrl || null,
|
||||
voiceDuration: voiceDuration || null,
|
||||
imageUrl: imageUrl || null,
|
||||
imageUrl: primaryImageUrl,
|
||||
images: {
|
||||
create: attachedImageUrls.map((url, index) => ({ url, position: index })),
|
||||
},
|
||||
annotationData: serializedAnnotationData,
|
||||
authorId: session?.user?.id || null,
|
||||
guestName: isGuest ? guestName : null,
|
||||
@@ -463,18 +447,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
replies: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: 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
|
||||
if (imageUrl) {
|
||||
const fileName = extractImageFileNameFromProxyUrl(imageUrl);
|
||||
// Every attached image also shows up in the assets pane
|
||||
for (const check of imageChecks) {
|
||||
const fileName = extractImageFileNameFromProxyUrl(check.url);
|
||||
const displayName = sanitizeAssetDisplayName(null, fileName || 'Comment Image');
|
||||
const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80);
|
||||
|
||||
@@ -484,9 +470,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
kind: 'IMAGE',
|
||||
provider: 'R2_IMAGE',
|
||||
displayName,
|
||||
sourceUrl: imageUrl,
|
||||
thumbnailUrl: imageUrl,
|
||||
sizeBytes: imageSizeBytes,
|
||||
sourceUrl: check.url,
|
||||
thumbnailUrl: check.url,
|
||||
sizeBytes: check.sizeBytes,
|
||||
uploadedByUserId: session?.user?.id || null,
|
||||
uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
|
||||
uploadedByGuestName: isGuest ? safeGuestName : null,
|
||||
@@ -543,7 +529,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
projectName: project.name,
|
||||
videoTitle,
|
||||
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',
|
||||
timestamp: ts,
|
||||
url: `${baseUrl}/watch/${version.video.id}`,
|
||||
@@ -554,7 +540,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
projectName: project.name,
|
||||
videoTitle,
|
||||
commentAuthor: commentAuthorName,
|
||||
commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
|
||||
commentText: content?.trim() || (primaryImageUrl ? '(image attachment)' : '(voice note)'),
|
||||
timestamp: ts,
|
||||
url: `${baseUrl}/watch/${version.video.id}`,
|
||||
}).catch((err) => logError('Notification failed:', err));
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
|
||||
const [assetReferenceCount, commentReferenceCount] = await Promise.all([
|
||||
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;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
if (asset.thumbnailUrl) {
|
||||
const [assetThumbnailCount, commentImageCount] = await Promise.all([
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -72,6 +73,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
|
||||
@@ -436,14 +436,14 @@ export function VideoPageContent({
|
||||
recordingTime,
|
||||
audioBlob,
|
||||
isUploadingAudio,
|
||||
imageBlob,
|
||||
setImageBlob,
|
||||
imageFiles,
|
||||
commentRangeStart,
|
||||
commentRangeEnd,
|
||||
toggleCommentRangeSelection,
|
||||
clearCommentRangeSelection,
|
||||
isUploadingImage,
|
||||
imageInputRef,
|
||||
removeImageFile,
|
||||
handleAddComment,
|
||||
handleImageSelect,
|
||||
handlePaste,
|
||||
@@ -460,8 +460,7 @@ export function VideoPageContent({
|
||||
isReplyRecording,
|
||||
replyRecordingTime,
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
setReplyImageBlob,
|
||||
replyImageFiles,
|
||||
replyRangeStart,
|
||||
replyRangeEnd,
|
||||
toggleReplyRangeSelection,
|
||||
@@ -475,7 +474,6 @@ export function VideoPageContent({
|
||||
cancelReplyRecording,
|
||||
submitReplyWithMedia,
|
||||
editingCommentId,
|
||||
setEditingCommentId,
|
||||
editText,
|
||||
setEditText,
|
||||
editTagId,
|
||||
@@ -484,6 +482,13 @@ export function VideoPageContent({
|
||||
setEditAnnotationData,
|
||||
isEditingAnnotation,
|
||||
setIsEditingAnnotation,
|
||||
editImageUrls,
|
||||
editImageFiles,
|
||||
editImageInputRef,
|
||||
startEditingComment,
|
||||
startEditingReply,
|
||||
cancelEditingComment,
|
||||
removeEditImageUrl,
|
||||
isSubmittingEdit,
|
||||
handleEditComment,
|
||||
handleDeleteComment,
|
||||
@@ -853,13 +858,17 @@ export function VideoPageContent({
|
||||
currentUserId={currentUserId}
|
||||
projectOwnerId={video.project.ownerId}
|
||||
editingCommentId={editingCommentId}
|
||||
setEditingCommentId={setEditingCommentId}
|
||||
startEditingComment={startEditingComment}
|
||||
startEditingReply={startEditingReply}
|
||||
cancelEditingComment={cancelEditingComment}
|
||||
editText={editText}
|
||||
setEditText={setEditText}
|
||||
editTagId={editTagId}
|
||||
setEditTagId={setEditTagId}
|
||||
setEditAnnotationData={setEditAnnotationData}
|
||||
setIsEditingAnnotation={setIsEditingAnnotation}
|
||||
editImageUrls={editImageUrls}
|
||||
editImageFiles={editImageFiles}
|
||||
editImageInputRef={editImageInputRef}
|
||||
removeEditImageUrl={removeEditImageUrl}
|
||||
onStartEditAnnotation={commentsActions.onStartEditAnnotation}
|
||||
isSubmittingEdit={isSubmittingEdit}
|
||||
availableTags={availableTags}
|
||||
@@ -888,9 +897,9 @@ export function VideoPageContent({
|
||||
stopReplyRecording={stopReplyRecording}
|
||||
cancelReplyRecording={cancelReplyRecording}
|
||||
replyAudioBlob={replyAudioBlob}
|
||||
replyImageBlob={replyImageBlob}
|
||||
setReplyImageBlob={setReplyImageBlob}
|
||||
replyImageFiles={replyImageFiles}
|
||||
replyImageInputRef={replyImageInputRef}
|
||||
removeImageFile={removeImageFile}
|
||||
handleImageSelect={handleImageSelect}
|
||||
handlePaste={handlePaste}
|
||||
handleDrop={handleDrop}
|
||||
@@ -931,9 +940,9 @@ export function VideoPageContent({
|
||||
stopRecording={stopRecording}
|
||||
cancelRecording={cancelRecording}
|
||||
audioBlob={audioBlob}
|
||||
imageBlob={imageBlob}
|
||||
imageFiles={imageFiles}
|
||||
imageInputRef={imageInputRef}
|
||||
setImageBlob={setImageBlob}
|
||||
removeImageFile={(index) => removeImageFile(index, 'comment')}
|
||||
commentText={commentText}
|
||||
setCommentText={setCommentText}
|
||||
commentRangeStart={commentRangeStart}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { AssetListSection } from '@/components/video-page/asset-list-section';
|
||||
import type { DirectUploadProvider, VideoAsset } from '@/components/video-page/types';
|
||||
import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload';
|
||||
import {
|
||||
extractPastedImageFile,
|
||||
extractPastedImageFiles,
|
||||
validateImageFile,
|
||||
} from '@/components/video-page/image-upload-utils';
|
||||
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>) => {
|
||||
if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return;
|
||||
const pastedImage = extractPastedImageFile(event.clipboardData);
|
||||
if (!pastedImage) return;
|
||||
const pastedImages = extractPastedImageFiles(event.clipboardData);
|
||||
if (pastedImages.length === 0) return;
|
||||
event.preventDefault();
|
||||
await stageImageFiles([pastedImage]);
|
||||
await stageImageFiles(pastedImages);
|
||||
};
|
||||
|
||||
const handleCreateYoutubeAsset = async () => {
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
|
||||
import { memo, type RefObject } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Mic,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
Send,
|
||||
Tag,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -23,6 +12,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
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 type { CommentTag, VideoAsset } from '@/components/video-page/types';
|
||||
|
||||
@@ -32,9 +23,9 @@ interface CommentComposerProps {
|
||||
stopRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
audioBlob: Blob | null;
|
||||
imageBlob: File | null;
|
||||
imageFiles: File[];
|
||||
imageInputRef: RefObject<HTMLInputElement | null>;
|
||||
setImageBlob: (blob: File | null) => void;
|
||||
removeImageFile: (index: number) => void;
|
||||
commentText: string;
|
||||
setCommentText: (value: string) => void;
|
||||
commentRangeStart: number | null;
|
||||
@@ -58,8 +49,8 @@ interface CommentComposerProps {
|
||||
handleAddComment: () => void;
|
||||
isSubmittingComment: boolean;
|
||||
startRecording: () => void;
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void;
|
||||
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void;
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
|
||||
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
availableTags: CommentTag[];
|
||||
selectedTagId: string | null;
|
||||
setSelectedTagId: (value: string | null) => void;
|
||||
@@ -75,9 +66,9 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
audioBlob,
|
||||
imageBlob,
|
||||
imageFiles,
|
||||
imageInputRef,
|
||||
setImageBlob,
|
||||
removeImageFile,
|
||||
commentText,
|
||||
setCommentText,
|
||||
commentRangeStart,
|
||||
@@ -179,28 +170,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{imageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={URL.createObjectURL(imageBlob)}
|
||||
alt="Preview"
|
||||
className="max-h-40 w-auto object-contain"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setImageBlob(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ImageAttachmentStrip files={imageFiles} onRemoveFile={removeImageFile} />
|
||||
|
||||
<MentionTextarea
|
||||
placeholder="Add a note to your voice comment (optional)..."
|
||||
@@ -271,28 +241,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{imageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={URL.createObjectURL(imageBlob)}
|
||||
alt="Preview"
|
||||
className="max-h-40 w-auto object-contain"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setImageBlob(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ImageAttachmentStrip files={imageFiles} onRemoveFile={removeImageFile} />
|
||||
<div className="mb-2 flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -332,7 +281,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
handleAddComment();
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, false)}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 self-end">
|
||||
@@ -340,7 +289,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
size="icon"
|
||||
onClick={handleAddComment}
|
||||
disabled={
|
||||
(!commentText.trim() && !imageBlob && !annotationStrokes) ||
|
||||
(!commentText.trim() && imageFiles.length === 0 && !annotationStrokes) ||
|
||||
isSubmittingComment ||
|
||||
isUploadingImage
|
||||
}
|
||||
@@ -363,7 +312,8 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
size="icon"
|
||||
variant="outline"
|
||||
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" />
|
||||
</Button>
|
||||
@@ -387,6 +337,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
ref={imageInputRef}
|
||||
onChange={handleImageSelect}
|
||||
@@ -444,7 +395,9 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
)}
|
||||
</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 · paste or drop up to {MAX_COMMENT_IMAGES} images
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,19 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MentionTextarea } from '@/components/video-page/mention-textarea';
|
||||
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 {
|
||||
isMobileCommentsOpen: boolean;
|
||||
@@ -63,13 +75,17 @@ interface CommentsPaneProps {
|
||||
currentUserId: string | null;
|
||||
projectOwnerId: string;
|
||||
editingCommentId: string | null;
|
||||
setEditingCommentId: (id: string | null) => void;
|
||||
startEditingComment: (comment: Comment) => void;
|
||||
startEditingReply: (reply: CommentReply) => void;
|
||||
cancelEditingComment: () => void;
|
||||
editText: string;
|
||||
setEditText: (value: string) => void;
|
||||
editTagId: string | null | undefined;
|
||||
setEditTagId: (value: string | null | undefined) => void;
|
||||
setEditAnnotationData: (value: string | null | undefined) => void;
|
||||
setIsEditingAnnotation: (value: boolean) => void;
|
||||
editImageUrls: string[];
|
||||
editImageFiles: File[];
|
||||
editImageInputRef: RefObject<HTMLInputElement | null>;
|
||||
removeEditImageUrl: (url: string) => void;
|
||||
onStartEditAnnotation: () => void;
|
||||
isSubmittingEdit: boolean;
|
||||
availableTags: CommentTag[];
|
||||
@@ -94,7 +110,7 @@ interface CommentsPaneProps {
|
||||
handleReplyComment: (
|
||||
parentId: string,
|
||||
voiceData?: { url: string; duration: number },
|
||||
imageData?: { url: string }
|
||||
imageUrls?: string[]
|
||||
) => void;
|
||||
startReplyRecording: () => void;
|
||||
isReplyRecording: boolean;
|
||||
@@ -102,12 +118,12 @@ interface CommentsPaneProps {
|
||||
stopReplyRecording: () => void;
|
||||
cancelReplyRecording: () => void;
|
||||
replyAudioBlob: Blob | null;
|
||||
replyImageBlob: File | null;
|
||||
setReplyImageBlob: (file: File | null) => void;
|
||||
replyImageFiles: File[];
|
||||
replyImageInputRef: RefObject<HTMLInputElement | null>;
|
||||
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void;
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void;
|
||||
handleDrop: (e: React.DragEvent<HTMLDivElement>, isReply?: boolean) => void;
|
||||
removeImageFile: (index: number, target: ImageAttachTarget) => void;
|
||||
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, target?: ImageAttachTarget) => void;
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, target?: ImageAttachTarget) => void;
|
||||
handleDrop: (e: React.DragEvent<HTMLDivElement>, target?: ImageAttachTarget) => void;
|
||||
submitReplyWithMedia: (parentId: string) => void;
|
||||
isSubmittingReply: boolean;
|
||||
isUploadingReplyAudio: boolean;
|
||||
@@ -141,13 +157,17 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
currentUserId,
|
||||
projectOwnerId,
|
||||
editingCommentId,
|
||||
setEditingCommentId,
|
||||
startEditingComment,
|
||||
startEditingReply,
|
||||
cancelEditingComment,
|
||||
editText,
|
||||
setEditText,
|
||||
editTagId,
|
||||
setEditTagId,
|
||||
setEditAnnotationData,
|
||||
setIsEditingAnnotation,
|
||||
editImageUrls,
|
||||
editImageFiles,
|
||||
editImageInputRef,
|
||||
removeEditImageUrl,
|
||||
onStartEditAnnotation,
|
||||
isSubmittingEdit,
|
||||
availableTags,
|
||||
@@ -176,9 +196,9 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
stopReplyRecording,
|
||||
cancelReplyRecording,
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
setReplyImageBlob,
|
||||
replyImageFiles,
|
||||
replyImageInputRef,
|
||||
removeImageFile,
|
||||
handleImageSelect,
|
||||
handlePaste,
|
||||
handleDrop,
|
||||
@@ -241,12 +261,15 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
onDrop={(e) => {
|
||||
setIsPaneDraggingOver(false);
|
||||
if (activePane !== 'comments') return;
|
||||
handleDrop(e, replyingTo !== null);
|
||||
handleDrop(
|
||||
e,
|
||||
editingCommentId !== null ? 'edit' : replyingTo !== null ? 'reply' : 'comment'
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isPaneDraggingOver && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-primary bg-primary/10 pointer-events-none">
|
||||
<p className="text-sm font-medium text-primary">Drop image to attach</p>
|
||||
<p className="text-sm font-medium text-primary">Drop images to attach</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 p-4 border-b lg:cursor-default space-y-2">
|
||||
@@ -446,13 +469,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
Reply
|
||||
</DropdownMenuItem>
|
||||
{canEditComment && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingCommentId(comment.id);
|
||||
setEditText(comment.content || '');
|
||||
setEditTagId(comment.tag?.id || null);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => startEditingComment(comment)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -486,19 +503,28 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
handleEditComment(comment.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
setEditTagId(undefined);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
cancelEditingComment();
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, 'edit')}
|
||||
/>
|
||||
<ImageAttachmentStrip
|
||||
existingUrls={editImageUrls}
|
||||
onRemoveExisting={removeEditImageUrl}
|
||||
files={editImageFiles}
|
||||
onRemoveFile={(index) => removeImageFile(index, 'edit')}
|
||||
compact
|
||||
/>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleEditComment(comment.id)}
|
||||
disabled={!editText.trim() || isSubmittingEdit}
|
||||
disabled={
|
||||
(!editText.trim() &&
|
||||
editImageUrls.length === 0 &&
|
||||
editImageFiles.length === 0) ||
|
||||
isSubmittingEdit
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSubmittingEdit ? (
|
||||
@@ -510,17 +536,31 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
setEditTagId(undefined);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
}}
|
||||
onClick={cancelEditingComment}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
onClick={() => editImageInputRef.current?.click()}
|
||||
disabled={
|
||||
editImageUrls.length + editImageFiles.length >= MAX_COMMENT_IMAGES
|
||||
}
|
||||
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
ref={editImageInputRef}
|
||||
onChange={(e) => handleImageSelect(e, 'edit')}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={comment.annotationData ? 'default' : 'outline'}
|
||||
@@ -595,21 +635,13 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{comment.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setPreviewImage(comment.imageUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={comment.imageUrl}
|
||||
alt="Attachment"
|
||||
className="max-h-60 w-auto object-contain"
|
||||
<CommentImageGallery
|
||||
images={comment.images}
|
||||
onOpen={setPreviewImage}
|
||||
className="mb-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comment.voiceUrl && (
|
||||
<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>
|
||||
<DropdownMenuContent align="end">
|
||||
{canEditReply && (
|
||||
<DropdownMenuItem
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => startEditingReply(reply)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -762,16 +786,28 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
handleEditComment(reply.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
cancelEditingComment();
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, 'edit')}
|
||||
/>
|
||||
<ImageAttachmentStrip
|
||||
existingUrls={editImageUrls}
|
||||
onRemoveExisting={removeEditImageUrl}
|
||||
files={editImageFiles}
|
||||
onRemoveFile={(index) => removeImageFile(index, 'edit')}
|
||||
compact
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleEditComment(reply.id)}
|
||||
disabled={!editText.trim() || isSubmittingEdit}
|
||||
disabled={
|
||||
(!editText.trim() &&
|
||||
editImageUrls.length === 0 &&
|
||||
editImageFiles.length === 0) ||
|
||||
isSubmittingEdit
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSubmittingEdit ? (
|
||||
@@ -783,14 +819,32 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
}}
|
||||
onClick={cancelEditingComment}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
onClick={() => editImageInputRef.current?.click()}
|
||||
disabled={
|
||||
editImageUrls.length + editImageFiles.length >=
|
||||
MAX_COMMENT_IMAGES
|
||||
}
|
||||
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
ref={editImageInputRef}
|
||||
onChange={(e) => handleImageSelect(e, 'edit')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -804,21 +858,14 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{reply.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setPreviewImage(reply.imageUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={reply.imageUrl}
|
||||
alt="Attachment"
|
||||
className="max-h-40 w-auto object-contain"
|
||||
<CommentImageGallery
|
||||
images={reply.images}
|
||||
onOpen={setPreviewImage}
|
||||
compact
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{reply.voiceUrl && (
|
||||
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
|
||||
<Button
|
||||
@@ -943,30 +990,11 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{replyImageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={URL.createObjectURL(replyImageBlob)}
|
||||
alt="Preview"
|
||||
className="h-full object-contain"
|
||||
<ImageAttachmentStrip
|
||||
files={replyImageFiles}
|
||||
onRemoveFile={(index) => removeImageFile(index, 'reply')}
|
||||
compact
|
||||
/>
|
||||
<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
|
||||
value={replyText}
|
||||
@@ -1026,30 +1054,11 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{replyImageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={URL.createObjectURL(replyImageBlob)}
|
||||
alt="Preview"
|
||||
className="h-full object-contain"
|
||||
<ImageAttachmentStrip
|
||||
files={replyImageFiles}
|
||||
onRemoveFile={(index) => removeImageFile(index, 'reply')}
|
||||
compact
|
||||
/>
|
||||
<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">
|
||||
<MentionTextarea
|
||||
value={replyText}
|
||||
@@ -1069,7 +1078,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
setReplyText('');
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, true)}
|
||||
onPaste={(e) => handlePaste(e, 'reply')}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -1084,7 +1093,8 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
size="icon"
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
@@ -1092,9 +1102,10 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
ref={replyImageInputRef}
|
||||
onChange={(e) => handleImageSelect(e, true)}
|
||||
onChange={(e) => handleImageSelect(e, 'reply')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
@@ -1127,7 +1138,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
size="sm"
|
||||
onClick={() => handleReplyComment(comment.id)}
|
||||
disabled={
|
||||
(!replyText.trim() && !replyImageBlob) ||
|
||||
(!replyText.trim() && replyImageFiles.length === 0) ||
|
||||
isSubmittingReply ||
|
||||
isUploadingReplyImage
|
||||
}
|
||||
|
||||
@@ -17,15 +17,17 @@ import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/anno
|
||||
import type {
|
||||
Comment,
|
||||
CommentActionsConfig,
|
||||
CommentImage,
|
||||
CommentReply,
|
||||
CommentTag,
|
||||
Version,
|
||||
VideoData,
|
||||
} from '@/components/video-page/types';
|
||||
import {
|
||||
extractPastedImageFile,
|
||||
extractPastedImageFiles,
|
||||
validateImageFile,
|
||||
} from '@/components/video-page/image-upload-utils';
|
||||
import { MAX_COMMENT_IMAGES } from '@/lib/comment-images';
|
||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||
import { withWebmDuration } from '@/lib/webm-duration';
|
||||
import { ApiRequestError, apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||
@@ -53,6 +55,9 @@ interface UseCommentActionsParams extends CommentActionsConfig {
|
||||
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 {
|
||||
const mime = blob.type.split(';')[0].trim().toLowerCase();
|
||||
if (mime === 'audio/mp4') return 'recording.m4a';
|
||||
@@ -91,7 +96,7 @@ export function useCommentActions({
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
|
||||
const [imageBlob, setImageBlob] = useState<File | null>(null);
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const [commentRangeStart, setCommentRangeStart] = useState<number | null>(null);
|
||||
const [commentRangeEnd, setCommentRangeEnd] = useState<number | null>(null);
|
||||
@@ -108,7 +113,7 @@ export function useCommentActions({
|
||||
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
|
||||
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
|
||||
const [replyImageBlob, setReplyImageBlob] = useState<File | null>(null);
|
||||
const [replyImageFiles, setReplyImageFiles] = useState<File[]>([]);
|
||||
const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false);
|
||||
const [replyRangeStart, setReplyRangeStart] = useState<number | null>(null);
|
||||
const [replyRangeEnd, setReplyRangeEnd] = useState<number | null>(null);
|
||||
@@ -130,6 +135,10 @@ export function useCommentActions({
|
||||
undefined
|
||||
);
|
||||
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 [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
@@ -189,9 +198,98 @@ export function useCommentActions({
|
||||
[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(
|
||||
async (voiceData?: { url: string; duration: number }) => {
|
||||
if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating)
|
||||
if (
|
||||
!voiceData &&
|
||||
imageFiles.length === 0 &&
|
||||
!commentText.trim() &&
|
||||
!annotationStrokes &&
|
||||
!isAnnotating
|
||||
)
|
||||
return;
|
||||
if (!activeVersion || !activeVersionId) return;
|
||||
|
||||
@@ -206,14 +304,18 @@ export function useCommentActions({
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const commentTimestamp = commentRangeStart ?? currentTime;
|
||||
const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null;
|
||||
const hasImages = imageFiles.length > 0;
|
||||
const optimisticComment: Comment = {
|
||||
id: tempId,
|
||||
content: voiceData || imageBlob ? commentText.trim() || null : commentText,
|
||||
content: voiceData || hasImages ? commentText.trim() || null : commentText,
|
||||
timestamp: commentTimestamp,
|
||||
timestampEnd: commentRangeEnd,
|
||||
voiceUrl: voiceData?.url ?? 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,
|
||||
isResolved: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -238,7 +340,7 @@ export function useCommentActions({
|
||||
setCommentText('');
|
||||
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
|
||||
setAudioBlob(null);
|
||||
setImageBlob(null);
|
||||
setImageFiles([]);
|
||||
setAnnotationStrokes(null);
|
||||
setIsAnnotating(false);
|
||||
clearCommentRangeSelection();
|
||||
@@ -248,44 +350,22 @@ export function useCommentActions({
|
||||
isMutatingRef.current = true;
|
||||
|
||||
try {
|
||||
let imageData: { url: string } | undefined;
|
||||
let uploadedImageUrls: string[] = [];
|
||||
|
||||
if (imageBlob) {
|
||||
if (hasImages) {
|
||||
setIsUploadingImage(true);
|
||||
const imageFormData = new FormData();
|
||||
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 };
|
||||
uploadedImageUrls = await uploadImageFiles(imageFiles);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: voiceData || imageBlob ? commentText.trim() || null : commentText,
|
||||
content: voiceData || hasImages ? commentText.trim() || null : commentText,
|
||||
timestamp: commentTimestamp,
|
||||
...(commentRangeEnd !== null && { timestampEnd: commentRangeEnd }),
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(imageData && { imageUrl: imageData.url }),
|
||||
...(uploadedImageUrls.length > 0 && { imageUrls: uploadedImageUrls }),
|
||||
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
|
||||
...(selectedTagId && { tagId: selectedTagId }),
|
||||
...(effectiveStrokes && { annotationData: effectiveStrokes }),
|
||||
@@ -314,8 +394,8 @@ export function useCommentActions({
|
||||
};
|
||||
});
|
||||
|
||||
// If an image was attached, refresh the assets list
|
||||
if (imageData) {
|
||||
// If images were attached, refresh the assets list
|
||||
if (uploadedImageUrls.length > 0) {
|
||||
void fetchAssets();
|
||||
}
|
||||
} else {
|
||||
@@ -369,11 +449,10 @@ export function useCommentActions({
|
||||
currentUserName,
|
||||
selectedTagId,
|
||||
availableTags,
|
||||
imageBlob,
|
||||
imageFiles,
|
||||
uploadImageFiles,
|
||||
annotationStrokes,
|
||||
isAnnotating,
|
||||
videoId,
|
||||
getGuestUploadToken,
|
||||
annotationCanvasRef,
|
||||
setSelectedTagId,
|
||||
setAnnotationStrokes,
|
||||
@@ -386,63 +465,34 @@ export function useCommentActions({
|
||||
);
|
||||
|
||||
const handleImageSelect = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
async (e: ChangeEvent<HTMLInputElement>, target: ImageAttachTarget = 'comment') => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
// Clearing the input lets the same file be picked again after it is removed.
|
||||
e.target.value = '';
|
||||
await attachImageFiles(files, target);
|
||||
},
|
||||
[]
|
||||
[attachImageFiles]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
|
||||
const file = extractPastedImageFile(e.clipboardData);
|
||||
if (!file) return;
|
||||
async (e: ClipboardEvent<HTMLTextAreaElement>, target: ImageAttachTarget = 'comment') => {
|
||||
const files = extractPastedImageFiles(e.clipboardData);
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
await attachImageFiles(files, target);
|
||||
},
|
||||
[]
|
||||
[attachImageFiles]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>, isReply: boolean = false) => {
|
||||
const handleDrop = useCallback(
|
||||
async (e: DragEvent<HTMLDivElement>, target: ImageAttachTarget = 'comment') => {
|
||||
e.preventDefault();
|
||||
const file = extractPastedImageFile(e.dataTransfer);
|
||||
if (!file) return;
|
||||
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
}, []);
|
||||
const files = extractPastedImageFiles(e.dataTransfer);
|
||||
if (files.length === 0) return;
|
||||
await attachImageFiles(files, target);
|
||||
},
|
||||
[attachImageFiles]
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
@@ -543,13 +593,13 @@ export function useCommentActions({
|
||||
const submitCommentWithMedia = useCallback(async () => {
|
||||
if (!activeVersion) return;
|
||||
|
||||
if (audioBlob && !imageBlob && !commentText.trim()) {
|
||||
if (audioBlob && imageFiles.length === 0 && !commentText.trim()) {
|
||||
submitVoiceComment();
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioBlob) setIsUploadingAudio(true);
|
||||
if (imageBlob) setIsUploadingImage(true);
|
||||
if (imageFiles.length > 0) setIsUploadingImage(true);
|
||||
|
||||
try {
|
||||
let voiceData: { url: string; duration: number } | undefined;
|
||||
@@ -569,7 +619,7 @@ export function useCommentActions({
|
||||
|
||||
setAudioBlob(null);
|
||||
setRecordingTime(0);
|
||||
setImageBlob(null);
|
||||
setImageFiles([]);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to submit comment with media:', err);
|
||||
@@ -580,7 +630,7 @@ export function useCommentActions({
|
||||
}
|
||||
}, [
|
||||
audioBlob,
|
||||
imageBlob,
|
||||
imageFiles,
|
||||
activeVersion,
|
||||
recordingTime,
|
||||
commentText,
|
||||
@@ -676,21 +726,25 @@ export function useCommentActions({
|
||||
async (
|
||||
parentId: string,
|
||||
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;
|
||||
|
||||
const hasReplyImages = replyImageFiles.length > 0;
|
||||
const tempId = `temp-reply-${Date.now()}`;
|
||||
const replyTimestamp = replyRangeStart ?? currentTime;
|
||||
const optimisticReply: CommentReply = {
|
||||
id: tempId,
|
||||
content: voiceData || replyImageBlob ? replyText.trim() || null : replyText,
|
||||
content: voiceData || hasReplyImages ? replyText.trim() || null : replyText,
|
||||
timestamp: replyTimestamp,
|
||||
timestampEnd: replyRangeEnd,
|
||||
voiceUrl: voiceData?.url ?? 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,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||
@@ -723,43 +777,31 @@ export function useCommentActions({
|
||||
setReplyingTo(null);
|
||||
setReplyAudioBlob(null);
|
||||
setReplyRecordingTime(0);
|
||||
setReplyImageBlob(null);
|
||||
setReplyImageFiles([]);
|
||||
clearReplyRangeSelection();
|
||||
|
||||
setIsSubmittingReply(true);
|
||||
isMutatingRef.current = true;
|
||||
|
||||
try {
|
||||
let submittedImageData: { url: string } | undefined = imageData;
|
||||
let submittedImageUrls: string[] = alreadyUploadedImageUrls ?? [];
|
||||
|
||||
if (replyImageBlob && !imageData) {
|
||||
if (hasReplyImages && submittedImageUrls.length === 0) {
|
||||
setIsUploadingReplyImage(true);
|
||||
const imageFormData = new FormData();
|
||||
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 };
|
||||
submittedImageUrls = await uploadImageFiles(replyImageFiles);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: voiceData || submittedImageData ? replyText.trim() || null : replyText,
|
||||
content:
|
||||
voiceData || submittedImageUrls.length > 0 ? replyText.trim() || null : replyText,
|
||||
timestamp: replyTimestamp,
|
||||
...(replyRangeEnd !== null && { timestampEnd: replyRangeEnd }),
|
||||
parentId,
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(submittedImageData && { imageUrl: submittedImageData.url }),
|
||||
...(submittedImageUrls.length > 0 && { imageUrls: submittedImageUrls }),
|
||||
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
|
||||
}),
|
||||
});
|
||||
@@ -791,8 +833,8 @@ export function useCommentActions({
|
||||
};
|
||||
});
|
||||
|
||||
// If an image was attached, refresh the assets list
|
||||
if (submittedImageData) {
|
||||
// If images were attached, refresh the assets list
|
||||
if (submittedImageUrls.length > 0) {
|
||||
void fetchAssets();
|
||||
}
|
||||
} else {
|
||||
@@ -852,9 +894,8 @@ export function useCommentActions({
|
||||
isGuest,
|
||||
normalizedGuestName,
|
||||
currentUserName,
|
||||
replyImageBlob,
|
||||
videoId,
|
||||
getGuestUploadToken,
|
||||
replyImageFiles,
|
||||
uploadImageFiles,
|
||||
setVideo,
|
||||
fetchAssets,
|
||||
clearReplyRangeSelection,
|
||||
@@ -949,13 +990,13 @@ export function useCommentActions({
|
||||
async (parentId: string) => {
|
||||
if (!activeVersion) return;
|
||||
|
||||
if (replyAudioBlob && !replyImageBlob && !replyText.trim()) {
|
||||
if (replyAudioBlob && replyImageFiles.length === 0 && !replyText.trim()) {
|
||||
submitVoiceReply(parentId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (replyAudioBlob) setIsUploadingReplyAudio(true);
|
||||
if (replyImageBlob) setIsUploadingReplyImage(true);
|
||||
if (replyImageFiles.length > 0) setIsUploadingReplyImage(true);
|
||||
|
||||
try {
|
||||
let voiceData: { url: string; duration: number } | undefined;
|
||||
@@ -976,7 +1017,7 @@ export function useCommentActions({
|
||||
|
||||
setReplyAudioBlob(null);
|
||||
setReplyRecordingTime(0);
|
||||
setReplyImageBlob(null);
|
||||
setReplyImageFiles([]);
|
||||
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to submit reply with media:', err);
|
||||
@@ -988,7 +1029,7 @@ export function useCommentActions({
|
||||
},
|
||||
[
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
replyImageFiles,
|
||||
activeVersion,
|
||||
replyRecordingTime,
|
||||
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(
|
||||
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;
|
||||
|
||||
setIsSubmittingEdit(true);
|
||||
@@ -1016,7 +1089,12 @@ export function useCommentActions({
|
||||
}
|
||||
|
||||
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 (finalAnnotationData !== undefined) {
|
||||
body.annotationData =
|
||||
@@ -1028,10 +1106,21 @@ export function useCommentActions({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = (await res.json().catch(() => null)) as {
|
||||
data?: { images?: CommentImage[] };
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
|
||||
if (res.ok) {
|
||||
const editedTag = editTagId
|
||||
? availableTags.find((t) => t.id === editTagId) || 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) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
@@ -1045,6 +1134,7 @@ export function useCommentActions({
|
||||
return {
|
||||
...c,
|
||||
content: editText.trim(),
|
||||
images: savedImages,
|
||||
tag: editTagId !== undefined ? editedTag : c.tag,
|
||||
annotationData:
|
||||
finalAnnotationData !== undefined
|
||||
@@ -1054,7 +1144,9 @@ export function useCommentActions({
|
||||
return {
|
||||
...c,
|
||||
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);
|
||||
setEditText('');
|
||||
setEditTagId(undefined);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
cancelEditingComment();
|
||||
if (uploadedImageUrls.length > 0) {
|
||||
void fetchAssets();
|
||||
}
|
||||
if (finalAnnotationData !== undefined && finalAnnotationData) {
|
||||
try {
|
||||
const parsed = JSON.parse(finalAnnotationData);
|
||||
@@ -1079,9 +1170,13 @@ export function useCommentActions({
|
||||
} else if (finalAnnotationData === null) {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
} else {
|
||||
toastApiError(payload, 'Failed to save changes');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to edit comment:', err);
|
||||
} catch (error) {
|
||||
// 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 {
|
||||
setIsSubmittingEdit(false);
|
||||
isMutatingRef.current = false;
|
||||
@@ -1091,12 +1186,17 @@ export function useCommentActions({
|
||||
editText,
|
||||
editTagId,
|
||||
editAnnotationData,
|
||||
editImageFiles,
|
||||
editImageUrls,
|
||||
uploadImageFiles,
|
||||
cancelEditingComment,
|
||||
isEditingAnnotation,
|
||||
activeVersionId,
|
||||
availableTags,
|
||||
isGuest,
|
||||
normalizedGuestName,
|
||||
editAnnotationCanvasRef,
|
||||
fetchAssets,
|
||||
setVideo,
|
||||
setViewingAnnotation,
|
||||
]
|
||||
@@ -1203,14 +1303,15 @@ export function useCommentActions({
|
||||
recordingTime,
|
||||
audioBlob,
|
||||
isUploadingAudio,
|
||||
imageBlob,
|
||||
setImageBlob,
|
||||
imageFiles,
|
||||
setImageFiles,
|
||||
commentRangeStart,
|
||||
commentRangeEnd,
|
||||
toggleCommentRangeSelection,
|
||||
clearCommentRangeSelection,
|
||||
isUploadingImage,
|
||||
imageInputRef,
|
||||
removeImageFile,
|
||||
handleAddComment,
|
||||
handleImageSelect,
|
||||
handlePaste,
|
||||
@@ -1228,8 +1329,8 @@ export function useCommentActions({
|
||||
isReplyRecording,
|
||||
replyRecordingTime,
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
setReplyImageBlob,
|
||||
replyImageFiles,
|
||||
setReplyImageFiles,
|
||||
replyRangeStart,
|
||||
replyRangeEnd,
|
||||
toggleReplyRangeSelection,
|
||||
@@ -1253,6 +1354,13 @@ export function useCommentActions({
|
||||
setEditAnnotationData,
|
||||
isEditingAnnotation,
|
||||
setIsEditingAnnotation,
|
||||
editImageUrls,
|
||||
editImageFiles,
|
||||
editImageInputRef,
|
||||
startEditingComment,
|
||||
startEditingReply,
|
||||
cancelEditingComment,
|
||||
removeEditImageUrl,
|
||||
isSubmittingEdit,
|
||||
handleEditComment,
|
||||
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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -18,16 +18,22 @@ export async function validateImageFile(file: File): Promise<string | 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;
|
||||
if (!items) return null;
|
||||
if (!items) return [];
|
||||
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (!item.type.startsWith('image/')) continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) return file;
|
||||
if (file) files.push(file);
|
||||
}
|
||||
|
||||
return null;
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ export interface ApprovalRequest {
|
||||
decisions: ApprovalDecision[];
|
||||
}
|
||||
|
||||
export interface CommentImage {
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CommentReply {
|
||||
id: string;
|
||||
content: string | null;
|
||||
@@ -82,7 +87,7 @@ export interface CommentReply {
|
||||
timestampEnd: number | null;
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
images: CommentImage[];
|
||||
annotationData: string | null;
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
@@ -99,7 +104,7 @@ export interface Comment {
|
||||
timestampEnd: number | null;
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
images: CommentImage[];
|
||||
annotationData: string | null;
|
||||
isResolved: boolean;
|
||||
createdAt: string;
|
||||
@@ -210,7 +215,7 @@ export interface VideoPageCommentsActions {
|
||||
onReplyComment: (
|
||||
parentId: string,
|
||||
voiceData?: { url: string; duration: number },
|
||||
imageData?: { url: string }
|
||||
imageUrls?: string[]
|
||||
) => void;
|
||||
onSubmitReplyWithMedia: (parentId: string) => void;
|
||||
onStartEditAnnotation: () => void;
|
||||
|
||||
+4
-4
@@ -390,10 +390,10 @@ export async function getCachedUserMediaStorage(): Promise<
|
||||
|
||||
const [mediaComments, imageAssets, audioAssets] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }] },
|
||||
select: {
|
||||
voiceUrl: true,
|
||||
imageUrl: true,
|
||||
images: { select: { url: true } },
|
||||
version: {
|
||||
select: {
|
||||
video: {
|
||||
@@ -448,8 +448,8 @@ export async function getCachedUserMediaStorage(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
if (comment.imageUrl) {
|
||||
const keyParts = comment.imageUrl.split('/');
|
||||
for (const image of comment.images) {
|
||||
const keyParts = image.url.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
const r2Key = `images/${filename}`;
|
||||
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
|
||||
+9
-9
@@ -85,10 +85,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||
version: { videoParentId: videoId },
|
||||
},
|
||||
select: { voiceUrl: true, imageUrl: true },
|
||||
select: { voiceUrl: true, images: { select: { url: true } } },
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: {
|
||||
@@ -105,7 +105,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
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) => {
|
||||
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([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||
version: { video: { projectId } },
|
||||
},
|
||||
select: { voiceUrl: true, imageUrl: true },
|
||||
select: { voiceUrl: true, images: { select: { url: true } } },
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: {
|
||||
@@ -144,7 +144,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
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) => {
|
||||
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([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||
version: { video: { project: { workspaceId } } },
|
||||
},
|
||||
select: { voiceUrl: true, imageUrl: true },
|
||||
select: { voiceUrl: true, images: { select: { url: true } } },
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: {
|
||||
@@ -183,7 +183,7 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
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) => {
|
||||
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||
|
||||
@@ -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
@@ -6,13 +6,14 @@ import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
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 AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
||||
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 =
|
||||
/^\/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 =
|
||||
|
||||
@@ -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
@@ -432,8 +432,10 @@ model Comment {
|
||||
voiceUrl String? // URL to voice recording file
|
||||
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
|
||||
images CommentImage[]
|
||||
|
||||
// Annotation drawing data (JSON string of strokes)
|
||||
annotationData String? @db.Text
|
||||
@@ -481,6 +483,20 @@ model Comment {
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
name String // e.g., "Feedback", "Technical", "Urgent"
|
||||
|
||||
@@ -146,16 +146,21 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
|
||||
).userFeedbackScreenshot;
|
||||
|
||||
for (const group of chunk(urls, CHUNK_SIZE)) {
|
||||
const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows, versionRows] =
|
||||
await Promise.all([
|
||||
const [
|
||||
commentRows,
|
||||
commentImageRows,
|
||||
feedbackRows,
|
||||
feedbackAttachmentRows,
|
||||
assetRows,
|
||||
versionRows,
|
||||
] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
|
||||
},
|
||||
select: {
|
||||
voiceUrl: true,
|
||||
imageUrl: true,
|
||||
},
|
||||
where: { voiceUrl: { in: group } },
|
||||
select: { voiceUrl: true },
|
||||
}),
|
||||
db.commentImage.findMany({
|
||||
where: { url: { in: group } },
|
||||
select: { url: true },
|
||||
}),
|
||||
db.userFeedback.findMany({
|
||||
where: { screenshotUrl: { in: group } },
|
||||
@@ -183,7 +188,9 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
|
||||
|
||||
for (const row of commentRows) {
|
||||
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) {
|
||||
if (row.screenshotUrl) referenced.add(row.screenshotUrl);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GET as getCommentRoute,
|
||||
PATCH as patchCommentRoute,
|
||||
} from '@/app/api/comments/[commentId]/route';
|
||||
import { isFreshAttachment } from '@/lib/upload-freshness';
|
||||
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
||||
import { signedInAs, signedOut } from '../helpers/session';
|
||||
import {
|
||||
@@ -28,6 +29,32 @@ import {
|
||||
seedVersion,
|
||||
} 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 = {
|
||||
points: [
|
||||
{ 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]', () => {
|
||||
it('returns 403 to a stranger and never exposes the project row', async () => {
|
||||
const scenario = await seedVersion();
|
||||
|
||||
@@ -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
|
||||
// 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 () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
|
||||
import type { Comment, CommentTag, VideoData } from '@/components/video-page/types';
|
||||
@@ -31,7 +31,7 @@ function makeComment(overrides: Partial<Comment> = {}): Comment {
|
||||
timestampEnd: null,
|
||||
voiceUrl: null,
|
||||
voiceDuration: null,
|
||||
imageUrl: null,
|
||||
images: [],
|
||||
annotationData: null,
|
||||
isResolved: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
@@ -79,7 +79,7 @@ function makeVideo(): VideoData {
|
||||
timestampEnd: null,
|
||||
voiceUrl: null,
|
||||
voiceDuration: null,
|
||||
imageUrl: null,
|
||||
images: [],
|
||||
annotationData: null,
|
||||
createdAt: '2026-01-01T00:01:00.000Z',
|
||||
author: { id: 'user2', name: 'Linus', image: null },
|
||||
@@ -495,7 +495,7 @@ describe('useCommentActions replying', () => {
|
||||
timestampEnd: null,
|
||||
voiceUrl: null,
|
||||
voiceDuration: null,
|
||||
imageUrl: null,
|
||||
images: [],
|
||||
annotationData: null,
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
author: { id: 'user1', name: 'Ada', image: null },
|
||||
@@ -815,6 +815,7 @@ describe('useCommentActions editing', () => {
|
||||
|
||||
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({
|
||||
content: 'Reworded note',
|
||||
imageUrls: [],
|
||||
});
|
||||
expect(findComment(harness, 'c1')?.tag).toEqual(TAGS[0]);
|
||||
});
|
||||
@@ -832,6 +833,7 @@ describe('useCommentActions editing', () => {
|
||||
|
||||
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({
|
||||
content: 'Reworded note',
|
||||
imageUrls: [],
|
||||
tagId: null,
|
||||
});
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -42,7 +42,7 @@ function makeComment(overrides: Partial<Comment> = {}): Comment {
|
||||
timestampEnd: null,
|
||||
voiceUrl: null,
|
||||
voiceDuration: null,
|
||||
imageUrl: null,
|
||||
images: [],
|
||||
annotationData: null,
|
||||
isResolved: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
@@ -325,7 +325,7 @@ describe('useVideoPageData loading comments', () => {
|
||||
timestampEnd: null,
|
||||
voiceUrl: null,
|
||||
voiceDuration: null,
|
||||
imageUrl: null,
|
||||
images: [],
|
||||
annotationData: null,
|
||||
createdAt: '2026-01-01T00:01:00.000Z',
|
||||
author: { id: 'user2', name: 'Linus', image: null },
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface CreateCommentInput {
|
||||
tagId?: string | null;
|
||||
annotationData?: string | null;
|
||||
imageUrl?: string | null;
|
||||
imageUrls?: string[];
|
||||
voiceUrl?: string | null;
|
||||
voiceDuration?: number | null;
|
||||
isResolved?: boolean;
|
||||
@@ -23,6 +24,8 @@ export interface CreateCommentInput {
|
||||
|
||||
export async function createComment(input: CreateCommentInput): Promise<Comment> {
|
||||
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({
|
||||
data: {
|
||||
versionId: input.versionId,
|
||||
@@ -36,7 +39,8 @@ export async function createComment(input: CreateCommentInput): Promise<Comment>
|
||||
parentId: input.parentId ?? null,
|
||||
tagId: input.tagId ?? 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,
|
||||
voiceDuration: input.voiceDuration ?? null,
|
||||
isResolved: input.isResolved ?? false,
|
||||
|
||||
@@ -68,6 +68,7 @@ const REVIEWED_MIGRATIONS = [
|
||||
'20260627140000_add_video_upload_multipart_id',
|
||||
'20260801120000_add_acquisition_analytics',
|
||||
'20260818120000_add_upload_reservation_purpose',
|
||||
'20260820120000_add_comment_images',
|
||||
];
|
||||
|
||||
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user