feat(comments): implement asset handling for attached images in comment creation

This commit is contained in:
Yusuf İpek
2026-02-26 12:45:23 +03:00
parent 4ea6099508
commit 60add2a1c4
5 changed files with 84 additions and 38 deletions
+64 -26
View File
@@ -9,6 +9,7 @@ import { getShareSessionFromRequest } from '@/lib/share-session';
import { HeadObjectCommand } from '@aws-sdk/client-s3'; import { HeadObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity'; import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
import { extractImageFileNameFromProxyUrl, sanitizeAssetDisplayName } from '@/lib/video-assets';
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_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;
@@ -184,7 +185,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
include: { include: {
video: { video: {
include: { include: {
project: true, project: {
include: {
workspace: {
select: {
ownerId: true,
},
},
},
},
}, },
}, },
}, },
@@ -266,35 +275,64 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
const comment = await db.comment.create({ // Use a transaction to create both comment and asset (if image is attached)
data: { const result = await db.$transaction(async (tx) => {
content: content?.trim() || null, const comment = await tx.comment.create({
timestamp: parsedTimestamp, data: {
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null, content: content?.trim() || null,
parentId: parentId || null, timestamp: parsedTimestamp,
voiceUrl: voiceUrl || null, timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
voiceDuration: voiceDuration || null, parentId: parentId || null,
imageUrl: imageUrl || null, voiceUrl: voiceUrl || null,
annotationData: annotationData || null, voiceDuration: voiceDuration || null,
authorId: session?.user?.id || null, imageUrl: imageUrl || null,
guestName: isGuest ? guestName : null, annotationData: annotationData || null,
guestEmail: isGuest ? guestEmail : null, authorId: session?.user?.id || null,
guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, guestName: isGuest ? guestName : null,
tagId: tagId || null, guestEmail: isGuest ? guestEmail : null,
versionId, guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null,
}, tagId: tagId || null,
include: { versionId,
author: { select: { id: true, name: true, image: true } }, },
tag: { select: { id: true, name: true, color: true } }, include: {
replies: { author: { select: { id: true, name: true, image: true } },
include: { tag: { select: { id: true, name: true, color: true } },
author: { select: { id: true, name: true, image: true } }, replies: {
tag: { select: { id: true, name: true, color: true } }, include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
}, },
}, },
}, });
// If an image was attached to the comment, also add it to the assets pane
if (imageUrl) {
const fileName = extractImageFileNameFromProxyUrl(imageUrl);
const displayName = sanitizeAssetDisplayName(null, fileName || 'Comment Image');
const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80);
await tx.videoAsset.create({
data: {
videoId: version.video.id,
kind: 'IMAGE',
provider: 'R2_IMAGE',
displayName,
sourceUrl: imageUrl,
thumbnailUrl: imageUrl,
uploadedByUserId: session?.user?.id || null,
uploadedByGuestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null,
uploadedByGuestName: isGuest ? safeGuestName : null,
billedUserId: project.workspace.ownerId,
},
});
}
return comment;
}); });
const comment = result;
// Notify project owner (fire-and-forget, skip self-notifications) // Notify project owner (fire-and-forget, skip self-notifications)
const commentAuthorName = session?.user?.name || guestName || 'Someone'; const commentAuthorName = session?.user?.name || guestName || 'Someone';
const isOwnProject = session?.user?.id === project.ownerId; const isOwnProject = session?.user?.id === project.ownerId;
@@ -18,7 +18,6 @@ type BunnyDownloadSource = {
const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240]; const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS); const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
const BUNNY_MAX_PROBE_CANDIDATES = 4;
const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000; const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000;
const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000; const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000;
const SAFE_DOWNLOAD_CONTENT_TYPE = 'application/octet-stream'; const SAFE_DOWNLOAD_CONTENT_TYPE = 'application/octet-stream';
@@ -152,17 +151,14 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
// Continue with static fallback list below. // Continue with static fallback list below.
} }
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])] const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])];
.slice(0, BUNNY_MAX_PROBE_CANDIDATES);
for (const height of candidateHeights) { for (const height of candidateHeights) {
const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`; const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`;
if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl; if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl;
} }
// Last-resort fallback return '';
const fallbackHeight = candidateHeights[0] ?? 1080;
return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`;
} }
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> { async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
+2
View File
@@ -154,6 +154,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
activeDownloadAssetId, activeDownloadAssetId,
hasMoreAssets, hasMoreAssets,
isLoadingMoreAssets, isLoadingMoreAssets,
fetchAssets,
loadMoreAssets, loadMoreAssets,
createAsset, createAsset,
deleteAsset, deleteAsset,
@@ -473,6 +474,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
annotationCanvasRef, annotationCanvasRef,
editAnnotationCanvasRef, editAnnotationCanvasRef,
fetchVersionComments, fetchVersionComments,
fetchAssets,
}); });
const commentMarkers = useMemo<CommentMarker[]>(() => { const commentMarkers = useMemo<CommentMarker[]>(() => {
@@ -37,6 +37,7 @@ interface UseCommentActionsParams extends CommentActionsConfig {
annotationCanvasRef: RefObject<AnnotationCanvasHandle | null>; annotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
editAnnotationCanvasRef: RefObject<AnnotationCanvasHandle | null>; editAnnotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
fetchVersionComments: (versionId: string, useEtag: boolean) => Promise<void>; fetchVersionComments: (versionId: string, useEtag: boolean) => Promise<void>;
fetchAssets: () => Promise<void>;
} }
export function useCommentActions({ export function useCommentActions({
@@ -61,6 +62,7 @@ export function useCommentActions({
annotationCanvasRef, annotationCanvasRef,
editAnnotationCanvasRef, editAnnotationCanvasRef,
fetchVersionComments, fetchVersionComments,
fetchAssets,
}: UseCommentActionsParams) { }: UseCommentActionsParams) {
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [isSubmittingComment, setIsSubmittingComment] = useState(false);
@@ -221,6 +223,11 @@ export function useCommentActions({
), ),
}; };
}); });
// If an image was attached, refresh the assets list
if (imageData) {
void fetchAssets();
}
} else { } else {
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
@@ -274,6 +281,7 @@ export function useCommentActions({
setIsAnnotating, setIsAnnotating,
setViewingAnnotation, setViewingAnnotation,
setVideo, setVideo,
fetchAssets,
]); ]);
const handleImageSelect = useCallback((e: ChangeEvent<HTMLInputElement>, isReply: boolean = false) => { const handleImageSelect = useCallback((e: ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
@@ -620,6 +628,11 @@ export function useCommentActions({
), ),
}; };
}); });
// If an image was attached, refresh the assets list
if (submittedImageData) {
void fetchAssets();
}
} else { } else {
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
@@ -666,7 +679,7 @@ export function useCommentActions({
setIsUploadingReplyImage(false); setIsUploadingReplyImage(false);
isMutatingRef.current = false; isMutatingRef.current = false;
} }
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, normalizedGuestName, currentUserName, replyImageBlob, videoId, getGuestUploadToken, setVideo]); }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, normalizedGuestName, currentUserName, replyImageBlob, videoId, getGuestUploadToken, setVideo, fetchAssets]);
const startReplyRecording = useCallback(async () => { const startReplyRecording = useCallback(async () => {
try { try {
+2 -5
View File
@@ -10,7 +10,6 @@ export type BunnyDownloadSource = {
const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240]; const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS); const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
const BUNNY_MAX_PROBE_CANDIDATES = 4;
const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000; const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000;
const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000; const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000;
@@ -88,16 +87,14 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
// Fall through to static fallback list. // Fall through to static fallback list.
} }
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])] const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])];
.slice(0, BUNNY_MAX_PROBE_CANDIDATES);
for (const height of candidateHeights) { for (const height of candidateHeights) {
const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`; const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`;
if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl; if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl;
} }
const fallbackHeight = candidateHeights[0] ?? 1080; return '';
return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`;
} }
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> { async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {