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 { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
import { extractImageFileNameFromProxyUrl, sanitizeAssetDisplayName } from '@/lib/video-assets';
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;
@@ -184,7 +185,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
include: {
video: {
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 comment = await db.comment.create({
data: {
content: content?.trim() || null,
timestamp: parsedTimestamp,
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
parentId: parentId || null,
voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null,
imageUrl: imageUrl || null,
annotationData: annotationData || null,
authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null,
guestEmail: isGuest ? guestEmail : null,
guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null,
tagId: tagId || null,
versionId,
},
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: {
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
// Use a transaction to create both comment and asset (if image is attached)
const result = await db.$transaction(async (tx) => {
const comment = await tx.comment.create({
data: {
content: content?.trim() || null,
timestamp: parsedTimestamp,
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
parentId: parentId || null,
voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null,
imageUrl: imageUrl || null,
annotationData: annotationData || null,
authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null,
guestEmail: isGuest ? guestEmail : null,
guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null,
tagId: tagId || null,
versionId,
},
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: {
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)
const commentAuthorName = session?.user?.name || guestName || 'Someone';
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_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_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000;
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.
}
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])]
.slice(0, BUNNY_MAX_PROBE_CANDIDATES);
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])];
for (const height of candidateHeights) {
const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`;
if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl;
}
// Last-resort fallback
const fallbackHeight = candidateHeights[0] ?? 1080;
return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`;
return '';
}
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
+2
View File
@@ -154,6 +154,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
activeDownloadAssetId,
hasMoreAssets,
isLoadingMoreAssets,
fetchAssets,
loadMoreAssets,
createAsset,
deleteAsset,
@@ -473,6 +474,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
annotationCanvasRef,
editAnnotationCanvasRef,
fetchVersionComments,
fetchAssets,
});
const commentMarkers = useMemo<CommentMarker[]>(() => {
@@ -37,6 +37,7 @@ interface UseCommentActionsParams extends CommentActionsConfig {
annotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
editAnnotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
fetchVersionComments: (versionId: string, useEtag: boolean) => Promise<void>;
fetchAssets: () => Promise<void>;
}
export function useCommentActions({
@@ -61,6 +62,7 @@ export function useCommentActions({
annotationCanvasRef,
editAnnotationCanvasRef,
fetchVersionComments,
fetchAssets,
}: UseCommentActionsParams) {
const [commentText, setCommentText] = useState('');
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 {
setVideo((prev) => {
if (!prev) return prev;
@@ -274,6 +281,7 @@ export function useCommentActions({
setIsAnnotating,
setViewingAnnotation,
setVideo,
fetchAssets,
]);
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 {
setVideo((prev) => {
if (!prev) return prev;
@@ -666,7 +679,7 @@ export function useCommentActions({
setIsUploadingReplyImage(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 () => {
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_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
const BUNNY_MAX_PROBE_CANDIDATES = 4;
const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 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.
}
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])]
.slice(0, BUNNY_MAX_PROBE_CANDIDATES);
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])];
for (const height of candidateHeights) {
const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`;
if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl;
}
const fallbackHeight = candidateHeights[0] ?? 1080;
return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`;
return '';
}
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {