mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(comments): implement asset handling for attached images in comment creation
This commit is contained in:
@@ -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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user