From 9ce033d3063d131a7ae72beb1611a513711d343f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Wed, 25 Feb 2026 18:34:03 +0300 Subject: [PATCH] feat(video-assets): add full video asset system (uploads, downloads, @mentions, and cleanup/billing integration) --- app/api/projects/[projectId]/route.ts | 31 + .../[projectId]/videos/[videoId]/route.ts | 19 +- .../assets/[assetId]/download/route.ts | 176 +++++ .../[videoId]/assets/[assetId]/route.ts | 89 +++ .../[videoId]/assets/bunny-init/route.ts | 158 ++++ app/api/videos/[videoId]/assets/route.ts | 385 +++++++++ app/api/watch/[videoId]/route.ts | 5 + app/api/workspaces/[workspaceId]/route.ts | 46 +- components/video-page-content.tsx | 60 ++ components/video-page/asset-list-section.tsx | 180 +++++ components/video-page/assets-pane.tsx | 742 ++++++++++++++++++ .../video-page/bunny-preview-player.tsx | 270 +++++++ components/video-page/comment-composer.tsx | 46 +- components/video-page/comment-rich-text.tsx | 90 +++ components/video-page/comments-pane.tsx | 174 ++-- .../video-page/hooks/use-comment-actions.ts | 43 +- .../video-page/hooks/use-video-assets.ts | 235 ++++++ .../video-page/hooks/use-video-player.ts | 4 + .../video-page/image-preview-dialog.tsx | 129 +-- components/video-page/image-upload-utils.ts | 29 + components/video-page/mention-textarea.tsx | 180 +++++ components/video-page/types.ts | 20 + components/video-page/video-page-header.tsx | 19 +- lib/admin-stats.ts | 102 ++- lib/bunny-download.ts | 194 +++++ lib/guest-upload-token.ts | 13 +- lib/r2-cleanup.ts | 78 +- lib/rate-limit.ts | 5 + lib/video-assets.ts | 155 ++++ prisma/schema.prisma | 41 + scripts/bunny-orphan-cleanup.ts | 28 +- scripts/r2-orphan-cleanup.ts | 9 +- 32 files changed, 3524 insertions(+), 231 deletions(-) create mode 100644 app/api/videos/[videoId]/assets/[assetId]/download/route.ts create mode 100644 app/api/videos/[videoId]/assets/[assetId]/route.ts create mode 100644 app/api/videos/[videoId]/assets/bunny-init/route.ts create mode 100644 app/api/videos/[videoId]/assets/route.ts create mode 100644 components/video-page/asset-list-section.tsx create mode 100644 components/video-page/assets-pane.tsx create mode 100644 components/video-page/bunny-preview-player.tsx create mode 100644 components/video-page/comment-rich-text.tsx create mode 100644 components/video-page/hooks/use-video-assets.ts create mode 100644 components/video-page/image-upload-utils.ts create mode 100644 components/video-page/mention-textarea.tsx create mode 100644 lib/bunny-download.ts create mode 100644 lib/video-assets.ts diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index 8c61d64..9996845 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db'; import { auth, checkProjectAccess } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup'; +import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -157,6 +158,36 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { return apiErrors.forbidden('Only the project owner can delete it'); } + const [projectVersionRefs, projectAssetRefs] = await Promise.all([ + db.videoVersion.findMany({ + where: { + video: { projectId }, + }, + select: { + providerId: true, + videoId: true, + }, + }), + db.videoAsset.findMany({ + where: { + video: { projectId }, + provider: 'BUNNY', + providerVideoId: { not: null }, + }, + select: { + providerVideoId: true, + }, + }), + ]); + + await cleanupBunnyStreamVideos([ + ...projectVersionRefs, + ...projectAssetRefs.map((asset) => ({ + providerId: 'bunny', + videoId: asset.providerVideoId as string, + })), + ]); + // Clean up voice files from R2 before cascade delete removes comment rows await cleanupProjectMediaFiles(projectId); diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index b4e9b9d..5937912 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -126,6 +126,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) { canManageTags: access.canEdit, canResolveComments: access.canEdit, canRequestApproval: access.canEdit, + canShareVideo: access.canEdit, + canUploadAssets: access.hasAccess, + canDownloadAssets: !!session?.user?.id && access.hasAccess, }); return withCacheControl(response, 'private, no-cache'); @@ -212,6 +215,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { videoId: true, }, }, + assets: { + select: { + provider: true, + providerVideoId: true, + }, + }, project: true, }, }); @@ -226,7 +235,15 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { } // Delete Bunny provider videos first to avoid orphaned assets. - await cleanupBunnyStreamVideos(video.versions); + await cleanupBunnyStreamVideos([ + ...video.versions, + ...video.assets + .filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId) + .map((asset) => ({ + providerId: 'bunny', + videoId: asset.providerVideoId as string, + })), + ]); // Clean up voice files from R2 before cascade delete removes comment rows await cleanupVideoMediaFiles(videoId); diff --git a/app/api/videos/[videoId]/assets/[assetId]/download/route.ts b/app/api/videos/[videoId]/assets/[assetId]/download/route.ts new file mode 100644 index 0000000..c3467db --- /dev/null +++ b/app/api/videos/[videoId]/assets/[assetId]/download/route.ts @@ -0,0 +1,176 @@ +import { NextRequest } from 'next/server'; +import { VideoAssetProvider } from '@prisma/client'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { rateLimit } from '@/lib/rate-limit'; +import { proxyR2MediaObject } from '@/lib/r2-media-proxy'; +import { fetchWithTimeout, resolveBunnyDownloadSource } from '@/lib/bunny-download'; +import { db } from '@/lib/db'; +import { + extractImageFileNameFromProxyUrl, + getVideoAssetAccessContext, +} from '@/lib/video-assets'; + +type RouteParams = { params: Promise<{ videoId: string; assetId: string }> }; +type BunnySourcePreference = 'auto' | 'original' | 'compressed'; + +const CONTENT_TYPE_BY_EXTENSION: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', +}; +const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]); + +function sanitizeFileName(value: string): string { + const sanitized = value + .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') + .replace(/\s+/g, ' ') + .trim(); + return sanitized.length > 0 ? sanitized : 'asset'; +} + +function toAsciiFileName(value: string): string { + const normalized = value + .normalize('NFKD') + .replace(/[^\x20-\x7E]/g, '') + .replace(/\s+/g, ' ') + .trim(); + return normalized.length > 0 ? normalized : 'asset'; +} + +function buildContentDisposition(fileNameWithExt: string): string { + const asciiFallback = toAsciiFileName(fileNameWithExt).replace(/["\\]/g, '_'); + const encoded = encodeURIComponent(fileNameWithExt); + return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`; +} + +function imageContentTypeFromFileName(fileName: string): string { + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream'; +} + +// GET /api/videos/[videoId]/assets/[assetId]/download +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'asset-download'); + if (limited) return limited; + + const { videoId, assetId } = await params; + const context = await getVideoAssetAccessContext(request, videoId, 'VIEW'); + if (!context) return apiErrors.notFound('Video'); + if (!context.hasViewAccess) return apiErrors.forbidden('Access denied'); + if (!context.viewerUserId || !context.canDownloadAssets) { + return apiErrors.forbidden('Asset downloads require an authenticated account'); + } + + const asset = await db.videoAsset.findFirst({ + where: { id: assetId, videoId }, + select: { + id: true, + provider: true, + displayName: true, + sourceUrl: true, + providerVideoId: true, + }, + }); + if (!asset) return apiErrors.notFound('Asset'); + if (asset.provider === VideoAssetProvider.YOUTUBE) { + return apiErrors.badRequest('YouTube assets cannot be downloaded'); + } + + if (asset.provider === VideoAssetProvider.R2_IMAGE) { + const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl); + if (!fileName) return apiErrors.badRequest('Invalid image asset URL'); + const key = `images/${fileName}`; + const extension = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png'; + const downloadName = `${sanitizeFileName(asset.displayName)}${extension}`; + const contentDisposition = buildContentDisposition(downloadName); + + return proxyR2MediaObject({ + request, + key, + fallbackContentType: imageContentTypeFromFileName(fileName), + cacheControl: 'private, no-store', + extraHeaders: { + 'Content-Disposition': contentDisposition, + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", + }, + internalErrorMessage: 'Failed to retrieve image', + }); + } + + const sourceParam = request.nextUrl.searchParams.get('source'); + const rawQuality = request.nextUrl.searchParams.get('quality'); + const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1'; + const requestedQuality = Number(rawQuality); + const sourcePreference: BunnySourcePreference = + sourceParam === null + ? 'auto' + : sourceParam === 'original' || sourceParam === 'compressed' + ? sourceParam + : 'auto'; + + if (sourceParam !== null && sourceParam !== 'original' && sourceParam !== 'compressed') { + return apiErrors.badRequest('Invalid source. Allowed values: original, compressed'); + } + if ( + rawQuality !== null + && (!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality)) + ) { + return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240'); + } + if (rawQuality !== null && sourcePreference === 'original') { + return apiErrors.badRequest('Quality cannot be used when source=original'); + } + + if (!asset.providerVideoId) { + return apiErrors.badRequest('Missing Bunny asset video id'); + } + + const source = await resolveBunnyDownloadSource( + asset.providerVideoId, + Number.isFinite(requestedQuality) ? requestedQuality : null, + sourcePreference + ); + if (!source) { + if (sourcePreference === 'original') { + return apiErrors.notFound('Original file'); + } + return apiErrors.notFound('Download file'); + } + + if (isPrepareOnly) { + const response = successResponse({ + quality: source.quality, + sourceType: source.sourceType, + }); + return withCacheControl(response, 'private, no-store'); + } + + const upstream = await fetchWithTimeout(source.url, { cache: 'no-store' }); + if (!upstream.ok || !upstream.body) { + return apiErrors.notFound('Download file'); + } + + const extension = source.sourceType === 'compressed' ? '.mp4' : ''; + const filename = `${sanitizeFileName(asset.displayName)}${extension}`; + const response = new Response(upstream.body, { + status: 200, + headers: { + 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream', + 'Content-Disposition': buildContentDisposition(filename), + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }); + const contentLength = upstream.headers.get('content-length'); + if (contentLength) response.headers.set('Content-Length', contentLength); + + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error downloading asset:', error); + return apiErrors.internalError('Failed to download asset'); + } +} diff --git a/app/api/videos/[videoId]/assets/[assetId]/route.ts b/app/api/videos/[videoId]/assets/[assetId]/route.ts new file mode 100644 index 0000000..3c655da --- /dev/null +++ b/app/api/videos/[videoId]/assets/[assetId]/route.ts @@ -0,0 +1,89 @@ +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { VideoAssetProvider } from '@prisma/client'; +import { NextRequest } from 'next/server'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { rateLimit } from '@/lib/rate-limit'; +import { db } from '@/lib/db'; +import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; +import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup'; +import { + canDeleteAssetForViewer, + extractImageKeyFromProxyUrl, + getVideoAssetAccessContext, +} from '@/lib/video-assets'; + +type RouteParams = { params: Promise<{ videoId: string; assetId: string }> }; + +// DELETE /api/videos/[videoId]/assets/[assetId] +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'asset-delete'); + if (limited) return limited; + + const { videoId, assetId } = await params; + const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT'); + if (!context) return apiErrors.notFound('Video'); + if (!context.canUploadAssets) return apiErrors.forbidden('Access denied'); + + const asset = await db.videoAsset.findFirst({ + where: { id: assetId, videoId }, + select: { + id: true, + provider: true, + sourceUrl: true, + providerVideoId: true, + uploadedByUserId: true, + uploadedByGuestIdentityId: true, + }, + }); + + if (!asset) return apiErrors.notFound('Asset'); + if (!canDeleteAssetForViewer(asset, context)) { + return apiErrors.forbidden('You can only delete assets you uploaded'); + } + + let shouldDeleteImageObject = false; + await db.$transaction(async (tx) => { + await tx.videoAsset.delete({ where: { id: asset.id } }); + + if (asset.provider === VideoAssetProvider.R2_IMAGE) { + const [assetReferenceCount, commentReferenceCount] = await Promise.all([ + tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }), + tx.comment.count({ where: { imageUrl: asset.sourceUrl } }), + ]); + shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0; + } + }); + + if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) { + const imageKey = extractImageKeyFromProxyUrl(asset.sourceUrl); + if (imageKey) { + try { + await r2Client.send(new DeleteObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: imageKey, + })); + } catch (error) { + console.error(`Failed to delete R2 image asset ${imageKey}:`, error); + } + } + } + + if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) { + try { + await cleanupBunnyStreamVideos([{ + providerId: 'bunny', + videoId: asset.providerVideoId, + }]); + } catch (error) { + console.error(`Failed to cleanup Bunny asset ${asset.providerVideoId}:`, error); + } + } + + const response = successResponse({ message: 'Asset deleted' }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error deleting video asset:', error); + return apiErrors.internalError('Failed to delete asset'); + } +} diff --git a/app/api/videos/[videoId]/assets/bunny-init/route.ts b/app/api/videos/[videoId]/assets/bunny-init/route.ts new file mode 100644 index 0000000..db88d1f --- /dev/null +++ b/app/api/videos/[videoId]/assets/bunny-init/route.ts @@ -0,0 +1,158 @@ +import crypto from 'crypto'; +import { NextRequest } from 'next/server'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { rateLimit } from '@/lib/rate-limit'; +import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token'; +import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup'; +import { + createGuestUploadToken, + deriveGuestUploadContext, + enforceGuestUploadQuota, + verifyGuestUploadToken, +} from '@/lib/guest-upload-token'; +import { getShareSessionFromRequest } from '@/lib/share-session'; +import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets'; + +type RouteParams = { params: Promise<{ videoId: string }> }; + +// POST /api/videos/[videoId]/assets/bunny-init +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'asset-bunny-init'); + if (limited) return limited; + + const { videoId } = await params; + const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT'); + if (!context) return apiErrors.notFound('Video'); + if (!context.canUploadAssets) return apiErrors.forbidden('Access denied'); + + const body = await request.json().catch(() => null); + const title = typeof body?.title === 'string' ? body.title.trim() : ''; + if (!title) return apiErrors.badRequest('Title is required'); + + const shareSession = getShareSessionFromRequest(request, context.video.id); + if (!context.viewerUserId) { + const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null); + if (quotaError) return quotaError; + } + + const apiKey = process.env.BUNNY_STREAM_API_KEY; + const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID; + if (!apiKey || !libraryId) { + return apiErrors.internalError('Bunny Stream is not configured correctly'); + } + + const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, { + method: 'POST', + headers: { + AccessKey: apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ title }), + }); + + if (!bunnyRes.ok) { + console.error('Failed to create Bunny Stream video asset', await bunnyRes.text()); + return apiErrors.internalError('Failed to initialize Bunny upload'); + } + + const bunnyVideo = await bunnyRes.json(); + const bunnyVideoId = typeof bunnyVideo?.guid === 'string' ? bunnyVideo.guid.trim() : ''; + if (!bunnyVideoId || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) { + return apiErrors.internalError('Upload provider did not return a valid video identifier'); + } + + const expirationTime = Math.floor(Date.now() / 1000) + 3600; + const hash = crypto.createHash('sha256'); + hash.update(libraryId + apiKey + expirationTime + bunnyVideoId); + const signature = hash.digest('hex'); + + let uploadToken = ''; + if (context.viewerUserId) { + uploadToken = createBunnyUploadToken({ + userId: context.viewerUserId, + projectId: context.video.projectId, + videoId: bunnyVideoId, + }, 3600); + } else { + const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null); + if (!expectedContext) { + return apiErrors.forbidden('Missing trusted client IP header'); + } + + uploadToken = createGuestUploadToken({ + projectId: context.video.projectId, + videoId: context.video.id, + intent: 'bunny', + context: expectedContext, + }, 3600); + } + + const response = successResponse({ + videoId: bunnyVideoId, + libraryId, + signature, + expirationTime, + uploadToken, + }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error initializing Bunny asset upload:', error); + return apiErrors.internalError('Failed to initialize asset upload'); + } +} + +// DELETE /api/videos/[videoId]/assets/bunny-init +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'asset-bunny-init'); + if (limited) return limited; + + const { videoId } = await params; + const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT'); + if (!context) return apiErrors.notFound('Video'); + if (!context.canUploadAssets) return apiErrors.forbidden('Access denied'); + + const body = await request.json().catch(() => null); + const bunnyVideoId = typeof body?.videoId === 'string' ? body.videoId.trim() : ''; + const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : ''; + if (!bunnyVideoId || !uploadToken || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) { + return apiErrors.badRequest('videoId and uploadToken are required'); + } + + if (context.viewerUserId) { + const isValidUploadToken = verifyBunnyUploadToken(uploadToken, { + userId: context.viewerUserId, + projectId: context.video.projectId, + videoId: bunnyVideoId, + }); + if (!isValidUploadToken) { + return apiErrors.forbidden('Invalid Bunny upload token'); + } + } else { + const shareSession = getShareSessionFromRequest(request, context.video.id); + const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null); + if (!expectedContext) { + return apiErrors.forbidden('Missing trusted client IP header'); + } + + const isValidUploadToken = verifyGuestUploadToken(uploadToken, { + projectId: context.video.projectId, + videoId: context.video.id, + intent: 'bunny', + context: expectedContext, + }); + if (!isValidUploadToken) { + return apiErrors.forbidden('Invalid Bunny upload token'); + } + } + + await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId: bunnyVideoId }]); + const response = successResponse({ message: 'Pending upload cleaned up' }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error cleaning up Bunny asset upload:', error); + return apiErrors.internalError('Failed to cleanup pending upload'); + } +} diff --git a/app/api/videos/[videoId]/assets/route.ts b/app/api/videos/[videoId]/assets/route.ts new file mode 100644 index 0000000..7804801 --- /dev/null +++ b/app/api/videos/[videoId]/assets/route.ts @@ -0,0 +1,385 @@ +import { HeadObjectCommand } from '@aws-sdk/client-s3'; +import { VideoAssetProvider } from '@prisma/client'; +import { NextRequest } from 'next/server'; +import { parseVideoUrl, getThumbnailUrl } from '@/lib/video-providers'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { rateLimit } from '@/lib/rate-limit'; +import { db } from '@/lib/db'; +import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; +import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token'; +import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-upload-token'; +import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity'; +import { getShareSessionFromRequest } from '@/lib/share-session'; +import { validateUrl, validateOptionalUrl } from '@/lib/validation'; +import { + SAFE_BUNNY_VIDEO_ID, + SAFE_IMAGE_PROXY_PATH, + canDeleteAssetForViewer, + extractImageFileNameFromProxyUrl, + extractImageKeyFromProxyUrl, + getVideoAssetAccessContext, + sanitizeAssetDisplayName, +} from '@/lib/video-assets'; + +type RouteParams = { params: Promise<{ videoId: string }> }; + +const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000; +const ASSET_LIST_DEFAULT_LIMIT = 40; +const ASSET_LIST_MAX_LIMIT = 100; +const BUNNY_ALLOWED_THUMBNAIL_HOSTS = new Set([ + 'iframe.mediadelivery.net', + 'video.bunnycdn.com', + 'vz-965f4f4a-fc1.b-cdn.net', +]); +const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000; + +type AssetWithViewerFields = { + id: string; + videoId: string; + kind: 'IMAGE' | 'VIDEO'; + provider: VideoAssetProvider; + displayName: string; + sourceUrl: string; + providerVideoId: string | null; + thumbnailUrl: string | null; + uploadedByUserId?: string | null; + uploadedByGuestName: string | null; + uploadedByGuestIdentityId?: string | null; + createdAt: Date; + updatedAt: Date; + uploadedByUser: { + id: string; + name: string | null; + image: string | null; + } | null; +}; + +type YouTubeTitleCacheRecord = { + title: string | null; + expiresAt: number; +}; + +const youtubeTitleCache = new Map(); + +function isAllowedBunnyMediaUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return false; + return BUNNY_ALLOWED_THUMBNAIL_HOSTS.has(parsed.hostname); + } catch { + return false; + } +} + +function shapeAssetForViewer(asset: AssetWithViewerFields, canExposeSource: boolean, canDelete: boolean) { + return { + id: asset.id, + videoId: asset.videoId, + kind: asset.kind, + provider: asset.provider, + displayName: asset.displayName, + sourceUrl: canExposeSource ? asset.sourceUrl : null, + providerVideoId: canExposeSource ? asset.providerVideoId : null, + thumbnailUrl: canExposeSource ? asset.thumbnailUrl : null, + uploadedByUserId: asset.uploadedByUserId ?? null, + uploadedByGuestName: asset.uploadedByGuestName, + createdAt: asset.createdAt, + updatedAt: asset.updatedAt, + uploadedByUser: asset.uploadedByUser, + canDelete, + }; +} + +function parsePaginationParam(value: string | null, fallback: number): number { + const parsed = Number.parseInt(value ?? '', 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return parsed; +} + +async function fetchYouTubeTitleFromProvider(videoId: string): Promise { + const url = `https://www.youtube.com/oembed?url=${encodeURIComponent(`https://www.youtube.com/watch?v=${videoId}`)}&format=json`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 4000); + + try { + const response = await fetch(url, { + method: 'GET', + signal: controller.signal, + cache: 'no-store', + }); + if (!response.ok) return null; + const payload = (await response.json().catch(() => null)) as { title?: string } | null; + if (!payload?.title || typeof payload.title !== 'string') return null; + return payload.title.trim() || null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +async function fetchYouTubeTitle(videoId: string): Promise { + const now = Date.now(); + const cached = youtubeTitleCache.get(videoId); + if (cached && cached.expiresAt > now) { + return cached.title; + } + + const title = await fetchYouTubeTitleFromProvider(videoId); + youtubeTitleCache.set(videoId, { + title, + expiresAt: now + YOUTUBE_TITLE_CACHE_TTL_MS, + }); + return title; +} + +async function isFreshImageAttachment(url: string): Promise { + const key = extractImageKeyFromProxyUrl(url); + if (!key) return false; + + try { + const head = await r2Client.send(new HeadObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + })); + if (!head.LastModified) return false; + return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; + } catch { + return false; + } +} + +// GET /api/videos/[videoId]/assets +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'asset-list'); + if (limited) return limited; + + const { videoId } = await params; + const context = await getVideoAssetAccessContext(request, videoId, 'VIEW'); + if (!context) return apiErrors.notFound('Video'); + if (!context.hasViewAccess) return apiErrors.forbidden('Access denied'); + + const requestedLimit = parsePaginationParam(request.nextUrl.searchParams.get('limit'), ASSET_LIST_DEFAULT_LIMIT); + const requestedOffset = parsePaginationParam(request.nextUrl.searchParams.get('offset'), 0); + const limit = Math.min(ASSET_LIST_MAX_LIMIT, Math.max(1, requestedLimit)); + const offset = requestedOffset; + const includeDeleteMetadata = context.canUploadAssets; + + const assets = await db.videoAsset.findMany({ + where: { videoId }, + skip: offset, + take: limit + 1, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + videoId: true, + kind: true, + provider: true, + displayName: true, + sourceUrl: true, + providerVideoId: true, + thumbnailUrl: true, + uploadedByUserId: includeDeleteMetadata, + uploadedByGuestName: true, + uploadedByGuestIdentityId: includeDeleteMetadata, + createdAt: true, + updatedAt: true, + uploadedByUser: { + select: { id: true, name: true, image: true }, + }, + }, + }); + const hasMore = assets.length > limit; + const pagedAssets = hasMore ? assets.slice(0, limit) : assets; + + const response = successResponse({ + assets: pagedAssets.map((asset) => shapeAssetForViewer( + asset, + context.canDownloadAssets, + includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false + )), + pagination: { + limit, + offset, + hasMore, + nextOffset: hasMore ? offset + limit : null, + }, + canUploadAssets: context.canUploadAssets, + canDownloadAssets: context.canDownloadAssets, + }); + return withCacheControl(response, 'private, no-cache'); + } catch (error) { + console.error('Error fetching video assets:', error); + return apiErrors.internalError('Failed to fetch assets'); + } +} + +// POST /api/videos/[videoId]/assets +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'asset-create'); + if (limited) return limited; + + const { videoId } = await params; + const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT'); + if (!context) return apiErrors.notFound('Video'); + if (!context.canUploadAssets) return apiErrors.forbidden('Access denied'); + + const body = await request.json().catch(() => null); + const provider = typeof body?.provider === 'string' ? body.provider.trim().toUpperCase() : ''; + + if (provider !== VideoAssetProvider.R2_IMAGE && provider !== VideoAssetProvider.YOUTUBE && provider !== VideoAssetProvider.BUNNY) { + return apiErrors.badRequest('Invalid provider'); + } + + const isGuest = !context.viewerUserId; + const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; + + const requestedDisplayName = typeof body?.displayName === 'string' ? body.displayName : null; + let displayName = ''; + let sourceUrl = ''; + let providerVideoId: string | null = null; + let thumbnailUrl: string | null = null; + let kind: 'IMAGE' | 'VIDEO' = 'IMAGE'; + + if (provider === VideoAssetProvider.R2_IMAGE) { + sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; + if (!SAFE_IMAGE_PROXY_PATH.test(sourceUrl)) { + return apiErrors.badRequest('Image URL must reference an uploaded image file'); + } + if (!(await isFreshImageAttachment(sourceUrl))) { + return apiErrors.badRequest('Image upload expired. Please upload again.'); + } + + const fileName = extractImageFileNameFromProxyUrl(sourceUrl); + displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Image'); + thumbnailUrl = sourceUrl; + kind = 'IMAGE'; + } + + if (provider === VideoAssetProvider.YOUTUBE) { + sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; + const parsedSource = parseVideoUrl(sourceUrl); + if (!parsedSource || parsedSource.providerId !== 'youtube') { + return apiErrors.badRequest('Only YouTube URLs are allowed for this provider'); + } + const sourceUrlError = validateUrl(parsedSource.originalUrl, 'YouTube URL'); + if (sourceUrlError) return apiErrors.badRequest(sourceUrlError); + + providerVideoId = parsedSource.videoId; + const youtubeTitle = await fetchYouTubeTitle(providerVideoId); + displayName = sanitizeAssetDisplayName(requestedDisplayName, youtubeTitle || `YouTube ${providerVideoId}`); + sourceUrl = parsedSource.originalUrl; + thumbnailUrl = getThumbnailUrl(parsedSource, 'large'); + kind = 'VIDEO'; + } + + if (provider === VideoAssetProvider.BUNNY) { + sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; + providerVideoId = typeof body?.providerVideoId === 'string' ? body.providerVideoId.trim() : ''; + const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : ''; + thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null; + + if (!providerVideoId || !SAFE_BUNNY_VIDEO_ID.test(providerVideoId)) { + return apiErrors.badRequest('Invalid Bunny video id'); + } + + const sourceUrlError = validateUrl(sourceUrl, 'Bunny source URL'); + if (sourceUrlError) return apiErrors.badRequest(sourceUrlError); + const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Bunny thumbnail URL'); + if (thumbnailUrlError) return apiErrors.badRequest(thumbnailUrlError); + if (thumbnailUrl && !isAllowedBunnyMediaUrl(thumbnailUrl)) { + return apiErrors.badRequest('Bunny thumbnail URL must use an approved Bunny host'); + } + if (!isAllowedBunnyMediaUrl(sourceUrl)) { + return apiErrors.badRequest('Bunny source URL must use an approved Bunny host'); + } + + if (!uploadToken) { + return apiErrors.badRequest('uploadToken is required'); + } + + if (context.viewerUserId) { + const isValidUploadToken = verifyBunnyUploadToken(uploadToken, { + userId: context.viewerUserId, + projectId: context.video.projectId, + videoId: providerVideoId, + }); + if (!isValidUploadToken) { + return apiErrors.forbidden('Invalid Bunny upload token'); + } + } else { + const shareSession = getShareSessionFromRequest(request, context.video.id); + const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null); + if (!expectedContext) { + return apiErrors.forbidden('Missing trusted client IP header'); + } + + const isValidGuestUploadToken = verifyGuestUploadToken(uploadToken, { + projectId: context.video.projectId, + videoId: context.video.id, + intent: 'bunny', + context: expectedContext, + }); + if (!isValidGuestUploadToken) { + return apiErrors.forbidden('Invalid Bunny upload token'); + } + } + + displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`); + if (!thumbnailUrl) { + thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${providerVideoId}/thumbnail.jpg`; + } + kind = 'VIDEO'; + } + + const created = await db.videoAsset.create({ + data: { + videoId: context.video.id, + kind, + provider, + displayName, + sourceUrl, + providerVideoId, + thumbnailUrl, + uploadedByUserId: context.viewerUserId, + uploadedByGuestIdentityId: context.viewerUserId ? null : guestIdentity?.identityId ?? null, + uploadedByGuestName: context.viewerUserId + ? null + : sanitizeAssetDisplayName(typeof body?.guestName === 'string' ? body.guestName : null, 'Guest'), + billedUserId: context.video.project.workspace.ownerId, + }, + select: { + id: true, + videoId: true, + kind: true, + provider: true, + displayName: true, + sourceUrl: true, + providerVideoId: true, + thumbnailUrl: true, + uploadedByGuestName: true, + createdAt: true, + updatedAt: true, + uploadedByUser: { + select: { id: true, name: true, image: true }, + }, + }, + }); + + const response = successResponse(shapeAssetForViewer( + created, + context.canDownloadAssets, + true + ), 201); + if (isGuest && guestIdentity?.shouldSetCookie) { + setGuestIdentityCookie(response, guestIdentity.identityId); + } + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error creating video asset:', error); + return apiErrors.internalError('Failed to create asset'); + } +} diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index d40bc7f..28bf927 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -182,6 +182,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests); const canDownloadWithMembership = access.hasAccess; const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload; + const canUploadAssets = canCommentWithMembership || canCommentWithShareLink; + const canDownloadAssets = !!session?.user?.id && (access.hasAccess || shareAccess.hasAccess); const response = successResponse({ ...videoData, versions, @@ -198,6 +200,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) { canDownload: canDownloadWithMembership || canDownloadWithShareLink, canManageTags: access.canEdit, canResolveComments: access.canEdit, + canShareVideo: access.canEdit, + canUploadAssets, + canDownloadAssets, }); return withCacheControl(response, 'private, no-cache'); diff --git a/app/api/workspaces/[workspaceId]/route.ts b/app/api/workspaces/[workspaceId]/route.ts index d4bbbfe..be418ca 100644 --- a/app/api/workspaces/[workspaceId]/route.ts +++ b/app/api/workspaces/[workspaceId]/route.ts @@ -166,20 +166,42 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { } // Delete Bunny provider videos first to avoid orphaned external assets. - const workspaceVersionRefs = await db.videoVersion.findMany({ - where: { - video: { - project: { - workspaceId, + const [workspaceVersionRefs, workspaceAssetRefs] = await Promise.all([ + db.videoVersion.findMany({ + where: { + video: { + project: { + workspaceId, + }, }, }, - }, - select: { - providerId: true, - videoId: true, - }, - }); - await cleanupBunnyStreamVideos(workspaceVersionRefs); + select: { + providerId: true, + videoId: true, + }, + }), + db.videoAsset.findMany({ + where: { + provider: 'BUNNY', + providerVideoId: { not: null }, + video: { + project: { + workspaceId, + }, + }, + }, + select: { + providerVideoId: true, + }, + }), + ]); + await cleanupBunnyStreamVideos([ + ...workspaceVersionRefs, + ...workspaceAssetRefs.map((asset) => ({ + providerId: 'bunny', + videoId: asset.providerVideoId as string, + })), + ]); // Clean up voice files from R2 before cascade delete removes comment rows await cleanupWorkspaceMediaFiles(workspaceId); diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index e2f4554..e0c11b7 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -23,6 +23,7 @@ import { useDownloadActions } from '@/components/video-page/hooks/use-download-a import { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync'; import { CommentComposer } from '@/components/video-page/comment-composer'; import { CommentsPane } from '@/components/video-page/comments-pane'; +import { AssetsPane } from '@/components/video-page/assets-pane'; import { ApprovalRequestDialog } from '@/components/video-page/approval-request-dialog'; import { ApprovalRequestsPanel } from '@/components/video-page/approval-requests-panel'; import type { @@ -34,6 +35,7 @@ import type { VideoPageHeaderActions, } from '@/components/video-page/types'; import { useApprovals } from '@/components/video-page/hooks/use-approvals'; +import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets'; function formatTime(seconds: number): string { const totalSeconds = Math.floor(seconds); @@ -92,6 +94,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi toggleVoiceSpeed, } = useCommentMedia(); const [showResolved, setShowResolved] = useState(false); + const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments'); + const [highlightedAssetId, setHighlightedAssetId] = useState(null); const editAnnotationCanvasRef = useRef(null); @@ -139,6 +143,29 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const isGuest = video ? !video.isAuthenticated : false; const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed; const normalizedGuestName = guestName.trim(); + const canUploadAssets = !!video?.canUploadAssets; + const canDownloadAssets = !!video?.canDownloadAssets; + + const { + assets, + isLoadingAssets, + isCreatingAsset, + activeDeleteAssetId, + activeDownloadAssetId, + hasMoreAssets, + isLoadingMoreAssets, + loadMoreAssets, + createAsset, + deleteAsset, + downloadAsset, + getGuestUploadToken, + } = useVideoAssets({ + videoId, + isAuthenticated: !!video?.isAuthenticated, + canUploadAssets, + canDownloadAssets, + guestName: normalizedGuestName, + }); const { showVersionDialog, @@ -181,6 +208,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setShowResolved(prev => !prev); }, []); + const handleAssetMentionClick = useCallback((assetId: string) => { + setActiveSidePane('assets'); + setHighlightedAssetId(assetId); + }, []); + const { isExportingCsv, isExportingPdf, exportComments } = useCommentExport({ activeVersionId, showResolved, @@ -191,6 +223,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const currentUserName = video?.currentUserName || null; const canResolveComments = !!video?.canResolveComments; const canRequestApproval = !!video?.canRequestApproval; + const canShareVideo = !!video?.canShareVideo; const { requests: approvalRequests, @@ -655,6 +688,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi onCreateVersion={headerActions.onCreateVersion} onOpenCompare={headerActions.onOpenCompare} canRequestApproval={canRequestApproval} + canShareVideo={canShareVideo} hasPendingApprovalRequest={!!activePendingRequest} onOpenApprovalRequest={handleOpenApprovalRequestDialog} onOpenApprovalsPanel={handleOpenApprovalsPanel} @@ -779,6 +813,31 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi isSubmittingReply={isSubmittingReply} isUploadingReplyAudio={isUploadingReplyAudio} isUploadingReplyImage={isUploadingReplyImage} + assets={assets} + onAssetMentionClick={handleAssetMentionClick} + activePane={activeSidePane} + setActivePane={setActiveSidePane} + assetsPane={( + setHighlightedAssetId(null)} + /> + )} composer={( )} /> diff --git a/components/video-page/asset-list-section.tsx b/components/video-page/asset-list-section.tsx new file mode 100644 index 0000000..ece234c --- /dev/null +++ b/components/video-page/asset-list-section.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { memo, type ReactNode } from 'react'; +import { Download, ExternalLink, Image as ImageIcon, Loader2, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; +import type { VideoAsset } from '@/components/video-page/types'; + +interface AssetListSectionProps { + assets: VideoAsset[]; + isLoadingAssets: boolean; + focusedAssetId: string | null; + bunnyProcessingByAssetId: Record; + activeDownloadAssetId: string | null; + activeDeleteAssetId: string | null; + canDownloadAssets: boolean; + hasMoreAssets: boolean; + isLoadingMoreAssets: boolean; + onViewAsset: (asset: VideoAsset) => void; + onDownloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => void; + onDeleteAsset: (assetId: string) => void; + onLoadMoreAssets: () => void; + renderAssetPreview: (asset: VideoAsset) => ReactNode; +} + +export const AssetListSection = memo(function AssetListSection({ + assets, + isLoadingAssets, + focusedAssetId, + bunnyProcessingByAssetId, + activeDownloadAssetId, + activeDeleteAssetId, + canDownloadAssets, + hasMoreAssets, + isLoadingMoreAssets, + onViewAsset, + onDownloadAsset, + onDeleteAsset, + onLoadMoreAssets, + renderAssetPreview, +}: AssetListSectionProps) { + if (isLoadingAssets) { + return ( +
+ + Loading assets... +
+ ); + } + + if (assets.length === 0) { + return ( +
+ No assets uploaded yet. +
+ ); + } + + return ( +
+ {assets.map((asset) => ( +
+ +
+
+

{asset.displayName}

+
+ {asset.provider === 'BUNNY' && bunnyProcessingByAssetId[asset.id] ? ( + + + Processing + + ) : null} +
+
+

+ {asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} • {new Date(asset.createdAt).toLocaleDateString()} +

+
+ + + {canDownloadAssets && asset.provider !== 'YOUTUBE' && ( + asset.provider === 'BUNNY' ? ( + + + + + + onDownloadAsset(asset, 'original')}> + + Original + + onDownloadAsset(asset, 'compressed')}> + + Compressed + + + + ) : ( + + ) + )} + + {asset.canDelete && ( + + )} +
+
+
+ ))} + + {hasMoreAssets ? ( + + ) : null} +
+ ); +}); diff --git a/components/video-page/assets-pane.tsx b/components/video-page/assets-pane.tsx new file mode 100644 index 0000000..af0ad37 --- /dev/null +++ b/components/video-page/assets-pane.tsx @@ -0,0 +1,742 @@ +'use client'; + +import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import * as tus from 'tus-js-client'; +import { toast } from 'sonner'; +import { Download, FileVideo, Image as ImageIcon, Loader2, UploadCloud, X, Youtube } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ImagePreviewDialog } from '@/components/video-page/image-preview-dialog'; +import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/video-page/bunny-preview-player'; +import { AssetListSection } from '@/components/video-page/asset-list-section'; +import type { VideoAsset } from '@/components/video-page/types'; +import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils'; + +interface AssetsPaneProps { + videoId: string; + assets: VideoAsset[]; + isLoadingAssets: boolean; + isCreatingAsset: boolean; + activeDeleteAssetId: string | null; + activeDownloadAssetId: string | null; + canUploadAssets: boolean; + canDownloadAssets: boolean; + getGuestUploadToken: (intent: 'image') => Promise; + createAsset: (payload: { + provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY'; + displayName?: string; + sourceUrl: string; + providerVideoId?: string; + thumbnailUrl?: string; + uploadToken?: string; + }) => Promise; + deleteAsset: (assetId: string) => Promise; + downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise; + hasMoreAssets: boolean; + isLoadingMoreAssets: boolean; + loadMoreAssets: () => Promise; + highlightedAssetId: string | null; + onHighlightedAssetHandled: () => void; +} + +export const AssetsPane = memo(function AssetsPane({ + videoId, + assets, + isLoadingAssets, + isCreatingAsset, + activeDeleteAssetId, + activeDownloadAssetId, + canUploadAssets, + canDownloadAssets, + getGuestUploadToken, + createAsset, + deleteAsset, + downloadAsset, + hasMoreAssets, + isLoadingMoreAssets, + loadMoreAssets, + highlightedAssetId, + onHighlightedAssetHandled, +}: AssetsPaneProps) { + const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny'>('image'); + const [imageTitle, setImageTitle] = useState(''); + const [pendingImageFile, setPendingImageFile] = useState(null); + const [youtubeUrl, setYoutubeUrl] = useState(''); + const [youtubeTitle, setYoutubeTitle] = useState(''); + const [bunnyTitle, setBunnyTitle] = useState(''); + const [isUploadingBunny, setIsUploadingBunny] = useState(false); + const [bunnyProgress, setBunnyProgress] = useState(0); + const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState>({}); + const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState>({}); + const [previewImage, setPreviewImage] = useState(null); + const [previewImageTitle, setPreviewImageTitle] = useState(null); + const [selectedAsset, setSelectedAsset] = useState(null); + const [focusedAssetId, setFocusedAssetId] = useState(null); + const bunnyPreviewPlayerRef = useRef(null); + const youtubeIframeRef = useRef(null); + const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false }); + const imageInputRef = useRef(null); + const bunnyInputRef = useRef(null); + + const sortedAssets = useMemo(() => { + return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)); + }, [assets]); + + useEffect(() => { + if (!highlightedAssetId) return; + + const element = document.getElementById(`asset-card-${highlightedAssetId}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setFocusedAssetId(highlightedAssetId); + window.setTimeout(() => setFocusedAssetId((prev) => (prev === highlightedAssetId ? null : prev)), 2500); + } + + onHighlightedAssetHandled(); + }, [highlightedAssetId, onHighlightedAssetHandled]); + + useEffect(() => { + if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return; + + const sendYouTubeCommand = (func: string, args: unknown[] = []) => { + const iframe = youtubeIframeRef.current; + if (!iframe?.contentWindow) return; + iframe.contentWindow.postMessage(JSON.stringify({ + event: 'command', + func, + args, + }), '*'); + }; + + const onMessage = (event: MessageEvent) => { + if (!selectedAsset || selectedAsset.provider !== 'YOUTUBE') return; + if (typeof event.data !== 'string') return; + let parsed: unknown; + try { + parsed = JSON.parse(event.data); + } catch { + return; + } + const info = (parsed as { info?: { currentTime?: number; playerState?: number; muted?: boolean } })?.info; + if (!info) return; + if (typeof info.currentTime === 'number') { + youtubePreviewStateRef.current.currentTime = info.currentTime; + } + if (typeof info.playerState === 'number') { + youtubePreviewStateRef.current.isPlaying = info.playerState === 1; + } + if (typeof info.muted === 'boolean') { + youtubePreviewStateRef.current.isMuted = info.muted; + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return; + const target = event.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return; + + const handledKeys = new Set(['Space', 'KeyK', 'ArrowLeft', 'ArrowRight', 'KeyJ', 'KeyL', 'KeyM', 'Escape']); + if (!handledKeys.has(event.code)) return; + + event.preventDefault(); + event.stopPropagation(); + + if (event.code === 'Escape') { + setSelectedAsset(null); + return; + } + + if (selectedAsset.provider === 'BUNNY') { + switch (event.code) { + case 'Space': + case 'KeyK': + bunnyPreviewPlayerRef.current?.togglePlayPause(); + break; + case 'ArrowLeft': + case 'KeyJ': + bunnyPreviewPlayerRef.current?.seekBy(-10); + break; + case 'ArrowRight': + case 'KeyL': + bunnyPreviewPlayerRef.current?.seekBy(10); + break; + case 'KeyM': + bunnyPreviewPlayerRef.current?.toggleMute(); + break; + } + return; + } + + if (selectedAsset.provider === 'YOUTUBE') { + switch (event.code) { + case 'Space': + case 'KeyK': { + const isPlaying = youtubePreviewStateRef.current.isPlaying; + sendYouTubeCommand(isPlaying ? 'pauseVideo' : 'playVideo'); + youtubePreviewStateRef.current.isPlaying = !isPlaying; + break; + } + case 'ArrowLeft': + case 'KeyJ': { + const next = Math.max(0, youtubePreviewStateRef.current.currentTime - 10); + sendYouTubeCommand('seekTo', [next, true]); + youtubePreviewStateRef.current.currentTime = next; + break; + } + case 'ArrowRight': + case 'KeyL': { + const next = youtubePreviewStateRef.current.currentTime + 10; + sendYouTubeCommand('seekTo', [next, true]); + youtubePreviewStateRef.current.currentTime = next; + break; + } + case 'KeyM': { + const isMuted = youtubePreviewStateRef.current.isMuted; + sendYouTubeCommand(isMuted ? 'unMute' : 'mute'); + youtubePreviewStateRef.current.isMuted = !isMuted; + break; + } + } + } + }; + + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('message', onMessage); + return () => { + window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('message', onMessage); + }; + }, [selectedAsset]); + + const handleImageUpload = async (file: File) => { + if (!file) return; + + const imageError = validateImageFile(file); + if (imageError) { + toast.error(imageError); + return; + } + + try { + const formData = new FormData(); + formData.append('image', file); + formData.append('videoId', videoId); + const guestUploadToken = await getGuestUploadToken('image'); + if (guestUploadToken) formData.append('uploadToken', guestUploadToken); + + const uploadRes = await fetch('/api/upload/image', { + method: 'POST', + body: formData, + }); + const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null; + const uploadedImageUrl = uploadPayload?.data?.url; + if (!uploadRes.ok || !uploadedImageUrl) { + toast.error(uploadPayload?.error || 'Failed to upload image'); + return; + } + + await createAsset({ + provider: 'R2_IMAGE', + sourceUrl: uploadedImageUrl, + displayName: imageTitle.trim() || file.name, + }); + if (imageInputRef.current) imageInputRef.current.value = ''; + setImageTitle(''); + setPendingImageFile(null); + } catch (error) { + console.error('Failed to upload image asset:', error); + toast.error('Failed to upload image'); + } + }; + + const handleImageFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const imageError = validateImageFile(file); + if (imageError) { + toast.error(imageError); + return; + } + setPendingImageFile(file); + toast.success('Image attached. Click Upload Image to send.'); + }; + + const handleImagePaste = (event: React.ClipboardEvent) => { + if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return; + const pastedImage = extractPastedImageFile(event.clipboardData); + if (!pastedImage) return; + const imageError = validateImageFile(pastedImage); + if (imageError) { + toast.error(imageError); + return; + } + event.preventDefault(); + setPendingImageFile(pastedImage); + toast.success('Image attached from clipboard. Click Upload Image to send.'); + }; + + const handleCreateYoutubeAsset = async () => { + if (!youtubeUrl.trim()) return; + const created = await createAsset({ + provider: 'YOUTUBE', + sourceUrl: youtubeUrl.trim(), + displayName: youtubeTitle.trim() || undefined, + }); + if (created) { + setYoutubeUrl(''); + setYoutubeTitle(''); + } + }; + + const handleBunnyUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith('video/')) { + toast.error('Please select a video file'); + return; + } + + let uploadedVideoId: string | null = null; + let uploadToken: string | null = null; + try { + setIsUploadingBunny(true); + setBunnyProgress(0); + + const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: bunnyTitle.trim() || file.name.replace(/\.[^/.]+$/, '') }), + }); + const initPayload = (await initRes.json().catch(() => null)) as { + data?: { + videoId: string; + libraryId: string; + signature: string; + expirationTime: number; + uploadToken: string; + }; + error?: string; + } | null; + + if (!initRes.ok || !initPayload?.data) { + toast.error(initPayload?.error || 'Failed to initialize Bunny upload'); + return; + } + + const initData = initPayload.data; + uploadedVideoId = initData.videoId; + uploadToken = initData.uploadToken; + + await new Promise((resolve, reject) => { + const upload = new tus.Upload(file, { + endpoint: 'https://video.bunnycdn.com/tusupload', + retryDelays: [0, 3000, 5000, 10000, 20000], + headers: { + AuthorizationSignature: initData.signature, + AuthorizationExpire: initData.expirationTime.toString(), + VideoId: initData.videoId, + LibraryId: initData.libraryId, + }, + metadata: { + filetype: file.type, + title: file.name, + }, + onError: (error) => reject(error), + onProgress: (bytesUploaded, bytesTotal) => { + const percentage = bytesTotal > 0 ? (bytesUploaded / bytesTotal) * 100 : 0; + setBunnyProgress(Math.min(100, Math.max(0, percentage))); + }, + onSuccess: () => resolve(), + }); + upload.start(); + }); + + const sourceUrl = `https://iframe.mediadelivery.net/embed/${initData.libraryId}/${initData.videoId}`; + const thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${initData.videoId}/thumbnail.jpg`; + const createdAsset = await createAsset({ + provider: 'BUNNY', + sourceUrl, + providerVideoId: initData.videoId, + uploadToken: initData.uploadToken, + thumbnailUrl, + displayName: bunnyTitle.trim() || file.name, + }); + if (!createdAsset) { + throw new Error('Failed to finalize Bunny asset'); + } + if (bunnyInputRef.current) bunnyInputRef.current.value = ''; + setBunnyTitle(''); + } catch (error) { + console.error('Failed to upload Bunny asset:', error); + toast.error('Failed to upload Bunny video'); + if (uploadedVideoId && uploadToken) { + await fetch(`/api/videos/${videoId}/assets/bunny-init`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }), + }).catch(() => undefined); + } + } finally { + setIsUploadingBunny(false); + setBunnyProgress(0); + } + }; + + const handleBunnyThumbnailError = (assetId: string) => { + setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: true })); + window.setTimeout(() => { + setBunnyThumbnailRetryKeyByAssetId((prev) => ({ ...prev, [assetId]: Date.now() })); + setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: false })); + }, 10000); + }; + + const renderAssetPreview = (asset: VideoAsset) => { + if (asset.kind === 'IMAGE') { + const imageSrc = asset.thumbnailUrl || asset.sourceUrl; + return ( +
+ {imageSrc ? ( + // eslint-disable-next-line @next/next/no-img-element + {asset.displayName} + ) : ( + + )} +
+ ); + } + + if (asset.provider === 'YOUTUBE' && asset.providerVideoId) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {asset.displayName} +
+ ); + } + + const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0; + const isProcessing = !!bunnyProcessingByAssetId[asset.id]; + const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null; + + return ( +
+ {thumbnailSrc ? ( + // eslint-disable-next-line @next/next/no-img-element + {asset.displayName} handleBunnyThumbnailError(asset.id)} + /> + ) : ( + + )} + {isProcessing && ( +
+ + Processing... +
+ )} +
+ ); + }; + + const handleOpenAsset = (asset: VideoAsset) => { + const isBunnyProcessing = asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id]; + if (isBunnyProcessing) { + toast.info('This Bunny asset is still processing.'); + return; + } + if (asset.kind === 'IMAGE') { + if (!asset.sourceUrl) { + toast.error('Preview is unavailable for this asset'); + return; + } + setPreviewImage(asset.sourceUrl); + setPreviewImageTitle(asset.displayName); + return; + } + setSelectedAsset(asset); + }; + + return ( +
+
+
+ Assets + {assets.length} +
+
+ + {canUploadAssets ? ( +
+ setUploadTab(value as 'image' | 'youtube' | 'bunny')}> + + Image + YouTube + Video + + + + {uploadTab === 'image' && ( +
+ setImageTitle(event.target.value)} + /> +

If set, this name will be used in @asset mentions.

+

Tip: you can paste an image here with Ctrl/Cmd+V.

+ {pendingImageFile ? ( +
+ Attached: {pendingImageFile.name} + +
+ ) : null} + + +
+ )} + + {uploadTab === 'youtube' && ( +
+ setYoutubeUrl(event.target.value)} + /> + setYoutubeTitle(event.target.value)} + /> + +
+ )} + + {uploadTab === 'bunny' && ( +
+ setBunnyTitle(event.target.value)} + /> +

If set, this name will be used in @asset mentions.

+ + + {isUploadingBunny && ( +
+
+
+ )} +
+ )} +
+ ) : ( +
+ You do not have permission to upload assets. +
+ )} + + void downloadAsset(asset, preference)} + onDeleteAsset={(assetId) => void deleteAsset(assetId)} + onLoadMoreAssets={() => void loadMoreAssets()} + renderAssetPreview={renderAssetPreview} + /> + + { + setPreviewImage(null); + setPreviewImageTitle(null); + }} + /> + + !open && setSelectedAsset(null)}> + setSelectedAsset(null)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === 'Escape') { + event.preventDefault(); + setSelectedAsset(null); + } + }} + > + {selectedAsset?.displayName || 'Video Preview'} + +
e.stopPropagation()}> +
+

+ {selectedAsset?.displayName || 'Video Preview'} +

+ {selectedAsset?.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? ( + + ) : null} + {selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? ( + + + + + + void downloadAsset(selectedAsset, 'original')}> + + Original + + void downloadAsset(selectedAsset, 'compressed')}> + + Compressed + + + + ) : null} + +
+ +
+ {selectedAsset ? ( + selectedAsset.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? ( +
+