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