mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(video-assets): add full video asset system (uploads, downloads, @mentions, and cleanup/billing integration)
This commit is contained in:
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
|
|||||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
|
import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
|
||||||
|
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
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');
|
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
|
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||||
await cleanupProjectMediaFiles(projectId);
|
await cleanupProjectMediaFiles(projectId);
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
canManageTags: access.canEdit,
|
canManageTags: access.canEdit,
|
||||||
canResolveComments: access.canEdit,
|
canResolveComments: access.canEdit,
|
||||||
canRequestApproval: access.canEdit,
|
canRequestApproval: access.canEdit,
|
||||||
|
canShareVideo: access.canEdit,
|
||||||
|
canUploadAssets: access.hasAccess,
|
||||||
|
canDownloadAssets: !!session?.user?.id && access.hasAccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
return withCacheControl(response, 'private, no-cache');
|
return withCacheControl(response, 'private, no-cache');
|
||||||
@@ -212,6 +215,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
videoId: true,
|
videoId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
assets: {
|
||||||
|
select: {
|
||||||
|
provider: true,
|
||||||
|
providerVideoId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
project: true,
|
project: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -226,7 +235,15 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete Bunny provider videos first to avoid orphaned assets.
|
// 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
|
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||||
await cleanupVideoMediaFiles(videoId);
|
await cleanupVideoMediaFiles(videoId);
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, YouTubeTitleCacheRecord>();
|
||||||
|
|
||||||
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,6 +182,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||||
const canDownloadWithMembership = access.hasAccess;
|
const canDownloadWithMembership = access.hasAccess;
|
||||||
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
||||||
|
const canUploadAssets = canCommentWithMembership || canCommentWithShareLink;
|
||||||
|
const canDownloadAssets = !!session?.user?.id && (access.hasAccess || shareAccess.hasAccess);
|
||||||
const response = successResponse({
|
const response = successResponse({
|
||||||
...videoData,
|
...videoData,
|
||||||
versions,
|
versions,
|
||||||
@@ -198,6 +200,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
|
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
|
||||||
canManageTags: access.canEdit,
|
canManageTags: access.canEdit,
|
||||||
canResolveComments: access.canEdit,
|
canResolveComments: access.canEdit,
|
||||||
|
canShareVideo: access.canEdit,
|
||||||
|
canUploadAssets,
|
||||||
|
canDownloadAssets,
|
||||||
});
|
});
|
||||||
|
|
||||||
return withCacheControl(response, 'private, no-cache');
|
return withCacheControl(response, 'private, no-cache');
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete Bunny provider videos first to avoid orphaned external assets.
|
// Delete Bunny provider videos first to avoid orphaned external assets.
|
||||||
const workspaceVersionRefs = await db.videoVersion.findMany({
|
const [workspaceVersionRefs, workspaceAssetRefs] = await Promise.all([
|
||||||
|
db.videoVersion.findMany({
|
||||||
where: {
|
where: {
|
||||||
video: {
|
video: {
|
||||||
project: {
|
project: {
|
||||||
@@ -178,8 +179,29 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
providerId: true,
|
providerId: true,
|
||||||
videoId: true,
|
videoId: true,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
await cleanupBunnyStreamVideos(workspaceVersionRefs);
|
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
|
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||||
await cleanupWorkspaceMediaFiles(workspaceId);
|
await cleanupWorkspaceMediaFiles(workspaceId);
|
||||||
|
|||||||
@@ -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 { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync';
|
||||||
import { CommentComposer } from '@/components/video-page/comment-composer';
|
import { CommentComposer } from '@/components/video-page/comment-composer';
|
||||||
import { CommentsPane } from '@/components/video-page/comments-pane';
|
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 { ApprovalRequestDialog } from '@/components/video-page/approval-request-dialog';
|
||||||
import { ApprovalRequestsPanel } from '@/components/video-page/approval-requests-panel';
|
import { ApprovalRequestsPanel } from '@/components/video-page/approval-requests-panel';
|
||||||
import type {
|
import type {
|
||||||
@@ -34,6 +35,7 @@ import type {
|
|||||||
VideoPageHeaderActions,
|
VideoPageHeaderActions,
|
||||||
} from '@/components/video-page/types';
|
} from '@/components/video-page/types';
|
||||||
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
||||||
|
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
const totalSeconds = Math.floor(seconds);
|
const totalSeconds = Math.floor(seconds);
|
||||||
@@ -92,6 +94,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
toggleVoiceSpeed,
|
toggleVoiceSpeed,
|
||||||
} = useCommentMedia();
|
} = useCommentMedia();
|
||||||
const [showResolved, setShowResolved] = useState(false);
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
|
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
|
||||||
|
const [highlightedAssetId, setHighlightedAssetId] = useState<string | null>(null);
|
||||||
|
|
||||||
const editAnnotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
|
const editAnnotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
|
||||||
|
|
||||||
@@ -139,6 +143,29 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const isGuest = video ? !video.isAuthenticated : false;
|
const isGuest = video ? !video.isAuthenticated : false;
|
||||||
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
|
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
|
||||||
const normalizedGuestName = guestName.trim();
|
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 {
|
const {
|
||||||
showVersionDialog,
|
showVersionDialog,
|
||||||
@@ -181,6 +208,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
setShowResolved(prev => !prev);
|
setShowResolved(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleAssetMentionClick = useCallback((assetId: string) => {
|
||||||
|
setActiveSidePane('assets');
|
||||||
|
setHighlightedAssetId(assetId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { isExportingCsv, isExportingPdf, exportComments } = useCommentExport({
|
const { isExportingCsv, isExportingPdf, exportComments } = useCommentExport({
|
||||||
activeVersionId,
|
activeVersionId,
|
||||||
showResolved,
|
showResolved,
|
||||||
@@ -191,6 +223,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const currentUserName = video?.currentUserName || null;
|
const currentUserName = video?.currentUserName || null;
|
||||||
const canResolveComments = !!video?.canResolveComments;
|
const canResolveComments = !!video?.canResolveComments;
|
||||||
const canRequestApproval = !!video?.canRequestApproval;
|
const canRequestApproval = !!video?.canRequestApproval;
|
||||||
|
const canShareVideo = !!video?.canShareVideo;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
requests: approvalRequests,
|
requests: approvalRequests,
|
||||||
@@ -655,6 +688,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
onCreateVersion={headerActions.onCreateVersion}
|
onCreateVersion={headerActions.onCreateVersion}
|
||||||
onOpenCompare={headerActions.onOpenCompare}
|
onOpenCompare={headerActions.onOpenCompare}
|
||||||
canRequestApproval={canRequestApproval}
|
canRequestApproval={canRequestApproval}
|
||||||
|
canShareVideo={canShareVideo}
|
||||||
hasPendingApprovalRequest={!!activePendingRequest}
|
hasPendingApprovalRequest={!!activePendingRequest}
|
||||||
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
|
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
|
||||||
onOpenApprovalsPanel={handleOpenApprovalsPanel}
|
onOpenApprovalsPanel={handleOpenApprovalsPanel}
|
||||||
@@ -779,6 +813,31 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
isSubmittingReply={isSubmittingReply}
|
isSubmittingReply={isSubmittingReply}
|
||||||
isUploadingReplyAudio={isUploadingReplyAudio}
|
isUploadingReplyAudio={isUploadingReplyAudio}
|
||||||
isUploadingReplyImage={isUploadingReplyImage}
|
isUploadingReplyImage={isUploadingReplyImage}
|
||||||
|
assets={assets}
|
||||||
|
onAssetMentionClick={handleAssetMentionClick}
|
||||||
|
activePane={activeSidePane}
|
||||||
|
setActivePane={setActiveSidePane}
|
||||||
|
assetsPane={(
|
||||||
|
<AssetsPane
|
||||||
|
videoId={videoId}
|
||||||
|
assets={assets}
|
||||||
|
isLoadingAssets={isLoadingAssets}
|
||||||
|
isCreatingAsset={isCreatingAsset}
|
||||||
|
activeDeleteAssetId={activeDeleteAssetId}
|
||||||
|
activeDownloadAssetId={activeDownloadAssetId}
|
||||||
|
canUploadAssets={canUploadAssets}
|
||||||
|
canDownloadAssets={canDownloadAssets}
|
||||||
|
getGuestUploadToken={getGuestUploadToken}
|
||||||
|
createAsset={createAsset}
|
||||||
|
deleteAsset={deleteAsset}
|
||||||
|
downloadAsset={downloadAsset}
|
||||||
|
hasMoreAssets={hasMoreAssets}
|
||||||
|
isLoadingMoreAssets={isLoadingMoreAssets}
|
||||||
|
loadMoreAssets={loadMoreAssets}
|
||||||
|
highlightedAssetId={highlightedAssetId}
|
||||||
|
onHighlightedAssetHandled={() => setHighlightedAssetId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
composer={(
|
composer={(
|
||||||
<CommentComposer
|
<CommentComposer
|
||||||
isRecording={isRecording}
|
isRecording={isRecording}
|
||||||
@@ -816,6 +875,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
canManageTags={!!video.canManageTags}
|
canManageTags={!!video.canManageTags}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
pauseVideoForAnnotation={composerActions.onPauseVideoForAnnotation}
|
pauseVideoForAnnotation={composerActions.onPauseVideoForAnnotation}
|
||||||
|
assets={assets}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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<string, boolean>;
|
||||||
|
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 (
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center gap-2 py-4">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading assets...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground py-4 text-center border rounded-lg">
|
||||||
|
No assets uploaded yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<div
|
||||||
|
key={asset.id}
|
||||||
|
id={`asset-card-${asset.id}`}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border p-2 flex gap-3 transition-colors',
|
||||||
|
focusedAssetId === asset.id && 'ring-2 ring-primary border-primary/60 bg-primary/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button className="shrink-0" onClick={() => onViewAsset(asset)}>
|
||||||
|
{renderAssetPreview(asset)}
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium truncate">{asset.displayName}</p>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{asset.provider === 'BUNNY' && bunnyProcessingByAssetId[asset.id] ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px] gap-1">
|
||||||
|
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||||
|
Processing
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} • {new Date(asset.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<div className="pt-1 flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title="View asset"
|
||||||
|
aria-label="View asset"
|
||||||
|
disabled={asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id]}
|
||||||
|
onClick={() => onViewAsset(asset)}
|
||||||
|
>
|
||||||
|
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : <ExternalLink className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
|
||||||
|
asset.provider === 'BUNNY' ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title="Download asset"
|
||||||
|
aria-label="Download asset"
|
||||||
|
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
|
||||||
|
>
|
||||||
|
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'original')}>
|
||||||
|
<Download className="h-3 w-3 mr-2" />
|
||||||
|
Original
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'compressed')}>
|
||||||
|
<Download className="h-3 w-3 mr-2" />
|
||||||
|
Compressed
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title="Download asset"
|
||||||
|
aria-label="Download asset"
|
||||||
|
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
|
||||||
|
onClick={() => onDownloadAsset(asset)}
|
||||||
|
>
|
||||||
|
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{asset.canDelete && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="destructive"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title="Delete asset"
|
||||||
|
aria-label="Delete asset"
|
||||||
|
disabled={activeDeleteAssetId === asset.id}
|
||||||
|
onClick={() => onDeleteAsset(asset.id)}
|
||||||
|
>
|
||||||
|
{activeDeleteAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasMoreAssets ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoadingMoreAssets}
|
||||||
|
onClick={onLoadMoreAssets}
|
||||||
|
>
|
||||||
|
{isLoadingMoreAssets ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
||||||
|
{isLoadingMoreAssets ? 'Loading more...' : 'Load more'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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<string | null>;
|
||||||
|
createAsset: (payload: {
|
||||||
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
||||||
|
displayName?: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
providerVideoId?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
uploadToken?: string;
|
||||||
|
}) => Promise<VideoAsset | null>;
|
||||||
|
deleteAsset: (assetId: string) => Promise<boolean>;
|
||||||
|
downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>;
|
||||||
|
hasMoreAssets: boolean;
|
||||||
|
isLoadingMoreAssets: boolean;
|
||||||
|
loadMoreAssets: () => Promise<void>;
|
||||||
|
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<File | null>(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<Record<string, boolean>>({});
|
||||||
|
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<Record<string, number>>({});
|
||||||
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
|
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
|
||||||
|
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
|
||||||
|
const [focusedAssetId, setFocusedAssetId] = useState<string | null>(null);
|
||||||
|
const bunnyPreviewPlayerRef = useRef<BunnyPreviewPlayerHandle | null>(null);
|
||||||
|
const youtubeIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false });
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bunnyInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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<void>((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 (
|
||||||
|
<div className="h-24 w-36 rounded border bg-black/20 flex items-center justify-center overflow-hidden">
|
||||||
|
{imageSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={imageSrc} alt={asset.displayName} className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.provider === 'YOUTUBE' && asset.providerVideoId) {
|
||||||
|
return (
|
||||||
|
<div className="h-24 w-36 rounded border overflow-hidden bg-black/70 flex items-center justify-center">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={asset.thumbnailUrl || `https://img.youtube.com/vi/${asset.providerVideoId}/mqdefault.jpg`}
|
||||||
|
alt={asset.displayName}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0;
|
||||||
|
const isProcessing = !!bunnyProcessingByAssetId[asset.id];
|
||||||
|
const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-24 w-36 rounded border overflow-hidden bg-muted relative flex items-center justify-center">
|
||||||
|
{thumbnailSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={thumbnailSrc}
|
||||||
|
alt={asset.displayName}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={() => handleBunnyThumbnailError(asset.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileVideo className="h-6 w-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="absolute inset-0 bg-black/65 flex flex-col items-center justify-center gap-1">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-white" />
|
||||||
|
<span className="text-[10px] text-white/90 font-medium">Processing...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4" onPaste={handleImagePaste}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">Assets</span>
|
||||||
|
<Badge variant="secondary">{assets.length}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canUploadAssets ? (
|
||||||
|
<div className="rounded-lg border p-3 space-y-3">
|
||||||
|
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny')}>
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="image">Image</TabsTrigger>
|
||||||
|
<TabsTrigger value="youtube">YouTube</TabsTrigger>
|
||||||
|
<TabsTrigger value="bunny">Video</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{uploadTab === 'image' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Optional name for mentions/tagging"
|
||||||
|
value={imageTitle}
|
||||||
|
onChange={(event) => setImageTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Tip: you can paste an image here with Ctrl/Cmd+V.</p>
|
||||||
|
{pendingImageFile ? (
|
||||||
|
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate">Attached: {pendingImageFile.name}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingImageFile(null);
|
||||||
|
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isCreatingAsset}
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingImageFile) {
|
||||||
|
void handleImageUpload(pendingImageFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
imageInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadCloud className="h-4 w-4 mr-2" />
|
||||||
|
{pendingImageFile ? 'Upload Image' : 'Select Image'}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadTab === 'youtube' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder="https://youtube.com/watch?v=..."
|
||||||
|
value={youtubeUrl}
|
||||||
|
onChange={(event) => setYoutubeUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Optional display name"
|
||||||
|
value={youtubeTitle}
|
||||||
|
onChange={(event) => setYoutubeTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={isCreatingAsset || !youtubeUrl.trim()}
|
||||||
|
onClick={handleCreateYoutubeAsset}
|
||||||
|
>
|
||||||
|
<Youtube className="h-4 w-4 mr-2" />
|
||||||
|
Add YouTube Asset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadTab === 'bunny' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Optional name for mentions/tagging"
|
||||||
|
value={bunnyTitle}
|
||||||
|
onChange={(event) => setBunnyTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isUploadingBunny || isCreatingAsset}
|
||||||
|
onClick={() => bunnyInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{isUploadingBunny ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
|
||||||
|
{isUploadingBunny ? 'Uploading...' : 'Upload Video'}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={bunnyInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleBunnyUpload}
|
||||||
|
/>
|
||||||
|
{isUploadingBunny && (
|
||||||
|
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
|
||||||
|
<div className="bg-primary h-2 rounded-full" style={{ width: `${bunnyProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border p-3 text-xs text-muted-foreground">
|
||||||
|
You do not have permission to upload assets.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AssetListSection
|
||||||
|
assets={sortedAssets}
|
||||||
|
isLoadingAssets={isLoadingAssets}
|
||||||
|
focusedAssetId={focusedAssetId}
|
||||||
|
bunnyProcessingByAssetId={bunnyProcessingByAssetId}
|
||||||
|
activeDownloadAssetId={activeDownloadAssetId}
|
||||||
|
activeDeleteAssetId={activeDeleteAssetId}
|
||||||
|
canDownloadAssets={canDownloadAssets}
|
||||||
|
hasMoreAssets={hasMoreAssets}
|
||||||
|
isLoadingMoreAssets={isLoadingMoreAssets}
|
||||||
|
onViewAsset={handleOpenAsset}
|
||||||
|
onDownloadAsset={(asset, preference) => void downloadAsset(asset, preference)}
|
||||||
|
onDeleteAsset={(assetId) => void deleteAsset(assetId)}
|
||||||
|
onLoadMoreAssets={() => void loadMoreAssets()}
|
||||||
|
renderAssetPreview={renderAssetPreview}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImagePreviewDialog
|
||||||
|
previewImage={previewImage}
|
||||||
|
title={previewImageTitle}
|
||||||
|
downloadFileName={previewImageTitle}
|
||||||
|
canDownload={canDownloadAssets}
|
||||||
|
onClose={() => {
|
||||||
|
setPreviewImage(null);
|
||||||
|
setPreviewImageTitle(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none rounded-none flex items-center justify-center"
|
||||||
|
onClick={() => setSelectedAsset(null)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedAsset(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">{selectedAsset?.displayName || 'Video Preview'}</DialogTitle>
|
||||||
|
|
||||||
|
<div className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
|
||||||
|
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={selectedAsset?.displayName || undefined}>
|
||||||
|
{selectedAsset?.displayName || 'Video Preview'}
|
||||||
|
</p>
|
||||||
|
{selectedAsset?.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`https://www.youtube.com/watch?v=${selectedAsset.providerVideoId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Open on YouTube
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
title="Download Bunny video"
|
||||||
|
aria-label="Download Bunny video"
|
||||||
|
disabled={
|
||||||
|
activeDownloadAssetId === selectedAsset.id
|
||||||
|
|| !!bunnyProcessingByAssetId[selectedAsset.id]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeDownloadAssetId === selectedAsset.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'original')}>
|
||||||
|
<Download className="h-3 w-3 mr-2" />
|
||||||
|
Original
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'compressed')}>
|
||||||
|
<Download className="h-3 w-3 mr-2" />
|
||||||
|
Compressed
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setSelectedAsset(null)}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 w-full p-2 sm:p-4">
|
||||||
|
{selectedAsset ? (
|
||||||
|
selectedAsset.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
|
||||||
|
<div className="w-full h-full rounded-md border overflow-hidden bg-black">
|
||||||
|
<iframe
|
||||||
|
ref={youtubeIframeRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
src={`https://www.youtube.com/embed/${selectedAsset.providerVideoId}?enablejsapi=1&rel=0&modestbranding=1&playsinline=1${typeof window !== 'undefined' ? `&origin=${encodeURIComponent(window.location.origin)}` : ''}`}
|
||||||
|
title={selectedAsset.displayName}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<BunnyPreviewPlayer
|
||||||
|
ref={bunnyPreviewPlayerRef}
|
||||||
|
providerVideoId={selectedAsset.providerVideoId}
|
||||||
|
isProcessing={!!bunnyProcessingByAssetId[selectedAsset.id]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
import { Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface BunnyPreviewPlayerProps {
|
||||||
|
providerVideoId: string | null;
|
||||||
|
isProcessing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BunnyPreviewPlayerHandle {
|
||||||
|
togglePlayPause: () => void;
|
||||||
|
seekBy: (seconds: number) => void;
|
||||||
|
toggleMute: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||||
|
|
||||||
|
function resolveBunnyCdnHostname(): string {
|
||||||
|
const configured = process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
||||||
|
if (!configured) return DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(configured);
|
||||||
|
return parsed.hostname || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
|
||||||
|
} catch {
|
||||||
|
return configured.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: number): string {
|
||||||
|
if (!Number.isFinite(value) || value < 0) return '0:00';
|
||||||
|
const total = Math.floor(value);
|
||||||
|
const minutes = Math.floor(total / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPreviewPlayerProps>(function BunnyPreviewPlayer({ providerVideoId, isProcessing }, ref) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const retryAttemptRef = useRef(0);
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
|
||||||
|
const playlistUrl = useMemo(() => {
|
||||||
|
if (!providerVideoId) return null;
|
||||||
|
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`;
|
||||||
|
}, [providerVideoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !playlistUrl) return;
|
||||||
|
|
||||||
|
let destroyed = false;
|
||||||
|
let usingHlsJs = false;
|
||||||
|
|
||||||
|
const clearRetry = () => {
|
||||||
|
if (retryTimerRef.current) {
|
||||||
|
clearTimeout(retryTimerRef.current);
|
||||||
|
retryTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRetry = (retryFn: () => void) => {
|
||||||
|
clearRetry();
|
||||||
|
retryTimerRef.current = setTimeout(() => {
|
||||||
|
if (!destroyed) retryFn();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRetryUrl = () => {
|
||||||
|
retryAttemptRef.current += 1;
|
||||||
|
const separator = playlistUrl.includes('?') ? '&' : '?';
|
||||||
|
return `${playlistUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
setIsReady(true);
|
||||||
|
setLoadError(false);
|
||||||
|
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
|
||||||
|
clearRetry();
|
||||||
|
};
|
||||||
|
const onPlay = () => setIsPlaying(true);
|
||||||
|
const onPause = () => setIsPlaying(false);
|
||||||
|
const onEnded = () => setIsPlaying(false);
|
||||||
|
const onTimeUpdate = () => setCurrentTime(video.currentTime || 0);
|
||||||
|
const onError = () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||||
|
setLoadError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadError(false);
|
||||||
|
scheduleRetry(() => {
|
||||||
|
if (usingHlsJs && hlsRef.current) {
|
||||||
|
hlsRef.current.loadSource(getRetryUrl());
|
||||||
|
hlsRef.current.startLoad(-1);
|
||||||
|
} else {
|
||||||
|
video.src = getRetryUrl();
|
||||||
|
video.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
video.addEventListener('play', onPlay);
|
||||||
|
video.addEventListener('pause', onPause);
|
||||||
|
video.addEventListener('ended', onEnded);
|
||||||
|
video.addEventListener('timeupdate', onTimeUpdate);
|
||||||
|
video.addEventListener('error', onError);
|
||||||
|
|
||||||
|
const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl');
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls();
|
||||||
|
hlsRef.current = hls;
|
||||||
|
usingHlsJs = true;
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
|
if (!destroyed) hls.loadSource(playlistUrl);
|
||||||
|
});
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
if (destroyed) return;
|
||||||
|
if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) {
|
||||||
|
scheduleRetry(() => hls.loadSource(getRetryUrl()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (canPlayNativeHls) {
|
||||||
|
video.src = playlistUrl;
|
||||||
|
video.load();
|
||||||
|
} else {
|
||||||
|
// Defer state update to avoid sync setState directly in effect body.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!destroyed) setLoadError(true);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
destroyed = true;
|
||||||
|
clearRetry();
|
||||||
|
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
video.removeEventListener('play', onPlay);
|
||||||
|
video.removeEventListener('pause', onPause);
|
||||||
|
video.removeEventListener('ended', onEnded);
|
||||||
|
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
|
video.removeEventListener('error', onError);
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
video.removeAttribute('src');
|
||||||
|
video.load();
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
setIsReady(false);
|
||||||
|
};
|
||||||
|
}, [playlistUrl]);
|
||||||
|
|
||||||
|
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !duration) return;
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||||
|
video.currentTime = ratio * duration;
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlayPause = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !isReady || isProcessing) return;
|
||||||
|
if (video.paused) void video.play();
|
||||||
|
else video.pause();
|
||||||
|
}, [isProcessing, isReady]);
|
||||||
|
|
||||||
|
const seekBy = useCallback((seconds: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !isReady || isProcessing || !duration) return;
|
||||||
|
video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds));
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
}, [duration, isProcessing, isReady]);
|
||||||
|
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const nextMuted = !video.muted;
|
||||||
|
video.muted = nextMuted;
|
||||||
|
setIsMuted(nextMuted);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
togglePlayPause,
|
||||||
|
seekBy,
|
||||||
|
toggleMute,
|
||||||
|
}), [seekBy, toggleMute, togglePlayPause]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full rounded-md border overflow-hidden bg-black flex flex-col">
|
||||||
|
<div className="relative flex-1 min-h-0 flex items-center justify-center bg-black" onClick={togglePlayPause}>
|
||||||
|
<video ref={videoRef} className="w-full h-full object-contain bg-black" playsInline preload="metadata" />
|
||||||
|
|
||||||
|
{(isProcessing || (!isReady && !loadError)) && (
|
||||||
|
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-2 text-white text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadError && !isProcessing && (
|
||||||
|
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
|
||||||
|
<p className="text-xs text-white/85">Unable to load Bunny preview right now.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t border-white/10 bg-black/70 px-2 py-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-white hover:text-white"
|
||||||
|
disabled={!isReady || isProcessing}
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-white hover:text-white"
|
||||||
|
disabled={!isReady}
|
||||||
|
onClick={toggleMute}
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-white/80 tabular-nums ml-1">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative h-6 rounded bg-white/10 select-none',
|
||||||
|
isReady && !isProcessing ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
|
||||||
|
)}
|
||||||
|
onClick={seekTo}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 h-full rounded bg-cyan-500/40 pointer-events-none"
|
||||||
|
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full w-1 rounded bg-cyan-400 pointer-events-none"
|
||||||
|
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -4,7 +4,6 @@ import { memo, type RefObject } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, Trash2, X } from 'lucide-react';
|
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, Trash2, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -13,7 +12,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import type { AnnotationStroke } from '@/components/annotation-canvas';
|
import type { AnnotationStroke } from '@/components/annotation-canvas';
|
||||||
import type { CommentTag } from '@/components/video-page/types';
|
import { MentionTextarea } from '@/components/video-page/mention-textarea';
|
||||||
|
import type { CommentTag, VideoAsset } from '@/components/video-page/types';
|
||||||
|
|
||||||
interface CommentComposerProps {
|
interface CommentComposerProps {
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
@@ -51,6 +51,7 @@ interface CommentComposerProps {
|
|||||||
canManageTags: boolean;
|
canManageTags: boolean;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
pauseVideoForAnnotation: () => void;
|
pauseVideoForAnnotation: () => void;
|
||||||
|
assets: VideoAsset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentComposer = memo(function CommentComposer({
|
export const CommentComposer = memo(function CommentComposer({
|
||||||
@@ -89,6 +90,7 @@ export const CommentComposer = memo(function CommentComposer({
|
|||||||
canManageTags,
|
canManageTags,
|
||||||
projectId,
|
projectId,
|
||||||
pauseVideoForAnnotation,
|
pauseVideoForAnnotation,
|
||||||
|
assets,
|
||||||
}: CommentComposerProps) {
|
}: CommentComposerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 p-4 border-t bg-background">
|
<div className="shrink-0 p-4 border-t bg-background">
|
||||||
@@ -168,10 +170,11 @@ export const CommentComposer = memo(function CommentComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Textarea
|
<MentionTextarea
|
||||||
placeholder="Add a note to your voice comment (optional)..."
|
placeholder="Add a note to your voice comment (optional)..."
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChange={(e) => setCommentText(e.target.value)}
|
onChange={setCommentText}
|
||||||
|
assets={assets}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="resize-none text-sm"
|
className="resize-none text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -222,13 +225,15 @@ export const CommentComposer = memo(function CommentComposer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-stretch">
|
||||||
<Textarea
|
<div className="flex-1 min-w-0">
|
||||||
|
<MentionTextarea
|
||||||
placeholder="Add a comment..."
|
placeholder="Add a comment..."
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChange={(e) => setCommentText(e.target.value)}
|
onChange={setCommentText}
|
||||||
rows={2}
|
assets={assets}
|
||||||
className="resize-none text-sm"
|
rows={6}
|
||||||
|
className="resize-none text-sm min-h-[180px] w-full"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
handleAddComment();
|
handleAddComment();
|
||||||
@@ -236,7 +241,8 @@ export const CommentComposer = memo(function CommentComposer({
|
|||||||
}}
|
}}
|
||||||
onPaste={(e) => handlePaste(e, false)}
|
onPaste={(e) => handlePaste(e, false)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 self-end">
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleAddComment}
|
onClick={handleAddComment}
|
||||||
@@ -329,7 +335,7 @@ export const CommentComposer = memo(function CommentComposer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
|
<p className="text-xs text-muted-foreground mt-2">Cmd+Enter to submit</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Image as ImageIcon, Video } from 'lucide-react';
|
||||||
|
import type { VideoAsset } from '@/components/video-page/types';
|
||||||
|
|
||||||
|
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
|
||||||
|
const ASSET_MENTION_REGEX = /@\[(.+?)\]\(asset:([a-z0-9]+)\)/gi;
|
||||||
|
|
||||||
|
interface CommentRichTextProps {
|
||||||
|
text: string;
|
||||||
|
onAssetMentionClick?: (assetId: string) => void;
|
||||||
|
assets?: VideoAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUrls(text: string): React.ReactNode[] {
|
||||||
|
const parts = text.split(URL_REGEX);
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
if (/^https?:\/\/[^\s]+$/.test(part)) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={`url-${index}`}
|
||||||
|
href={part}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline break-all"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <React.Fragment key={`txt-${index}`}>{part}</React.Fragment>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentRichText({ text, onAssetMentionClick, assets = [] }: CommentRichTextProps) {
|
||||||
|
const nodes: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const match of text.matchAll(ASSET_MENTION_REGEX)) {
|
||||||
|
const mentionIndex = match.index ?? -1;
|
||||||
|
if (mentionIndex < 0) continue;
|
||||||
|
|
||||||
|
if (mentionIndex > lastIndex) {
|
||||||
|
nodes.push(...renderUrls(text.slice(lastIndex, mentionIndex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackLabel = match[1] || 'asset';
|
||||||
|
const assetId = match[2] || '';
|
||||||
|
const matchedAsset = assets.find((asset) => asset.id === assetId);
|
||||||
|
const label = matchedAsset?.displayName || fallbackLabel;
|
||||||
|
const isVideoAsset = matchedAsset?.kind === 'VIDEO';
|
||||||
|
|
||||||
|
nodes.push(
|
||||||
|
<button
|
||||||
|
key={`mention-${assetId}-${mentionIndex}`}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex max-w-full items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-primary hover:bg-primary/20 transition-colors align-middle"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (assetId && onAssetMentionClick) onAssetMentionClick(assetId);
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isVideoAsset
|
||||||
|
? 'inline-flex h-4 shrink-0 items-center gap-0.5 rounded bg-violet-500/25 px-1 text-[9px] font-semibold tracking-wide text-violet-200'
|
||||||
|
: 'inline-flex h-4 shrink-0 items-center gap-0.5 rounded bg-emerald-500/25 px-1 text-[9px] font-semibold tracking-wide text-emerald-200'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isVideoAsset ? <Video className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
|
||||||
|
{isVideoAsset ? 'VID' : 'SS'}
|
||||||
|
</span>
|
||||||
|
<span className="truncate max-w-[190px] sm:max-w-[240px]">
|
||||||
|
@{label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
lastIndex = mentionIndex + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
nodes.push(...renderUrls(text.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{nodes}</>;
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { memo, type ReactNode, type RefObject } from 'react';
|
import { memo, type ReactNode, type RefObject } from 'react';
|
||||||
import { ArrowUpRight, CheckCircle2, Circle, Clock, Download, FileText, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
|
import { ArrowUpRight, CheckCircle2, ChevronDown, Circle, Clock, Download, FileText, FolderOpen, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -14,8 +13,9 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Linkify } from '@/components/linkify';
|
import { MentionTextarea } from '@/components/video-page/mention-textarea';
|
||||||
import type { Comment, CommentTag, Version } from '@/components/video-page/types';
|
import { CommentRichText } from '@/components/video-page/comment-rich-text';
|
||||||
|
import type { Comment, CommentTag, Version, VideoAsset } from '@/components/video-page/types';
|
||||||
|
|
||||||
interface CommentsPaneProps {
|
interface CommentsPaneProps {
|
||||||
isMobileCommentsOpen: boolean;
|
isMobileCommentsOpen: boolean;
|
||||||
@@ -79,6 +79,11 @@ interface CommentsPaneProps {
|
|||||||
isUploadingReplyAudio: boolean;
|
isUploadingReplyAudio: boolean;
|
||||||
isUploadingReplyImage: boolean;
|
isUploadingReplyImage: boolean;
|
||||||
composer: ReactNode;
|
composer: ReactNode;
|
||||||
|
assets: VideoAsset[];
|
||||||
|
onAssetMentionClick: (assetId: string) => void;
|
||||||
|
activePane: 'comments' | 'assets';
|
||||||
|
setActivePane: (pane: 'comments' | 'assets') => void;
|
||||||
|
assetsPane: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentsPane = memo(function CommentsPane({
|
export const CommentsPane = memo(function CommentsPane({
|
||||||
@@ -143,6 +148,11 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
isUploadingReplyAudio,
|
isUploadingReplyAudio,
|
||||||
isUploadingReplyImage,
|
isUploadingReplyImage,
|
||||||
composer,
|
composer,
|
||||||
|
assets,
|
||||||
|
onAssetMentionClick,
|
||||||
|
activePane,
|
||||||
|
setActivePane,
|
||||||
|
assetsPane,
|
||||||
}: CommentsPaneProps) {
|
}: CommentsPaneProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -161,22 +171,65 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
|
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
|
||||||
isFullscreenMode && !showComments ? 'hidden' : ''
|
isFullscreenMode && !showComments ? 'hidden' : ''
|
||||||
)}>
|
)}>
|
||||||
<div
|
<div className="shrink-0 p-4 border-b lg:cursor-default space-y-2">
|
||||||
className="shrink-0 flex items-center justify-between p-4 border-b lg:cursor-default"
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1 min-w-0 overflow-x-auto">
|
||||||
|
<Button
|
||||||
|
variant={activePane === 'comments' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0"
|
||||||
|
onClick={() => setActivePane('comments')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<MessageSquare className="h-4 w-4 mr-1" />
|
||||||
<MessageSquare className="h-5 w-5" />
|
Comments
|
||||||
<span className="font-medium">Comments</span>
|
<Badge variant="secondary" className="ml-2">{comments.length}</Badge>
|
||||||
<Badge variant="secondary">{comments.length}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}>
|
|
||||||
{showResolved ? 'Hide' : 'Show'} Resolved
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant={activePane === 'assets' ? 'default' : 'ghost'}
|
||||||
size="icon"
|
size="sm"
|
||||||
className="h-8 w-8"
|
className="h-8 shrink-0"
|
||||||
|
onClick={() => setActivePane('assets')}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4 mr-1" />
|
||||||
|
Assets
|
||||||
|
<Badge variant="secondary" className="ml-2">{assets.length}</Badge>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden shrink-0" onClick={() => setIsMobileCommentsOpen(false)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activePane === 'comments' && (
|
||||||
|
<div className="flex w-full items-center justify-end gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant={showResolved ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}
|
||||||
|
>
|
||||||
|
Resolved
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2"
|
||||||
|
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
||||||
|
aria-label="Download comments"
|
||||||
|
title="Download comments"
|
||||||
|
>
|
||||||
|
{isExportingCsv || isExportingPdf ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-4 w-4 ml-0.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
|
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -184,12 +237,10 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
}}
|
}}
|
||||||
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
|
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
|
||||||
>
|
>
|
||||||
{isExportingCsv ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
<Download className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Download CSV
|
||||||
<Button
|
</DropdownMenuItem>
|
||||||
variant="ghost"
|
<DropdownMenuItem
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -197,16 +248,17 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
}}
|
}}
|
||||||
title="Download comments as PDF"
|
title="Download comments as PDF"
|
||||||
>
|
>
|
||||||
{isExportingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-4 w-4" />}
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Download PDF
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setIsMobileCommentsOpen(false)}>
|
</DropdownMenuItem>
|
||||||
<X className="h-4 w-4" />
|
</DropdownMenuContent>
|
||||||
</Button>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
{filteredComments.length === 0 ? (
|
{activePane === 'assets' ? assetsPane : filteredComments.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
<p>No comments yet</p>
|
<p>No comments yet</p>
|
||||||
@@ -312,9 +364,10 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Textarea
|
<MentionTextarea
|
||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) => setEditText(e.target.value)}
|
onChange={setEditText}
|
||||||
|
assets={assets}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="resize-none text-sm mb-1"
|
className="resize-none text-sm mb-1"
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -402,7 +455,11 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
{comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>}
|
{comment.content && (
|
||||||
|
<p className="text-sm mb-2">
|
||||||
|
<CommentRichText text={comment.content} onAssetMentionClick={onAssetMentionClick} assets={assets} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{comment.imageUrl && (
|
{comment.imageUrl && (
|
||||||
<div
|
<div
|
||||||
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
@@ -530,9 +587,10 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
</div>
|
</div>
|
||||||
{isEditingReply ? (
|
{isEditingReply ? (
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<Textarea
|
<MentionTextarea
|
||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) => setEditText(e.target.value)}
|
onChange={setEditText}
|
||||||
|
assets={assets}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="resize-none text-sm mb-1"
|
className="resize-none text-sm mb-1"
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -567,7 +625,11 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
{reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>}
|
{reply.content && (
|
||||||
|
<p className="text-sm">
|
||||||
|
<CommentRichText text={reply.content} onAssetMentionClick={onAssetMentionClick} assets={assets} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{reply.imageUrl && (
|
{reply.imageUrl && (
|
||||||
<div
|
<div
|
||||||
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
@@ -689,9 +751,10 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Textarea
|
<MentionTextarea
|
||||||
value={replyText}
|
value={replyText}
|
||||||
onChange={(e) => setReplyText(e.target.value)}
|
onChange={setReplyText}
|
||||||
|
assets={assets}
|
||||||
placeholder="Add a note (optional)..."
|
placeholder="Add a note (optional)..."
|
||||||
rows={1}
|
rows={1}
|
||||||
className="resize-none text-sm"
|
className="resize-none text-sm"
|
||||||
@@ -725,9 +788,10 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Textarea
|
<MentionTextarea
|
||||||
value={replyText}
|
value={replyText}
|
||||||
onChange={(e) => setReplyText(e.target.value)}
|
onChange={setReplyText}
|
||||||
|
assets={assets}
|
||||||
placeholder="Write a reply..."
|
placeholder="Write a reply..."
|
||||||
rows={2}
|
rows={2}
|
||||||
className="resize-none text-sm flex-1"
|
className="resize-none text-sm flex-1"
|
||||||
@@ -807,7 +871,7 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{composer}
|
{activePane === 'comments' ? composer : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/annotation-canvas';
|
import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/annotation-canvas';
|
||||||
import type { Comment, CommentActionsConfig, CommentTag, Version, VideoData } from '@/components/video-page/types';
|
import type { Comment, CommentActionsConfig, CommentTag, Version, VideoData } from '@/components/video-page/types';
|
||||||
|
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
|
||||||
|
|
||||||
interface UseCommentActionsParams extends CommentActionsConfig {
|
interface UseCommentActionsParams extends CommentActionsConfig {
|
||||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||||
@@ -279,13 +280,9 @@ export function useCommentActions({
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (!file.type.startsWith('image/')) {
|
const imageError = validateImageFile(file);
|
||||||
toast.error('Please select an image file');
|
if (imageError) {
|
||||||
return;
|
toast.error(imageError);
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
toast.error('Image must be less than 10MB');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,27 +294,21 @@ export function useCommentActions({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
|
const handlePaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
|
||||||
const items = e.clipboardData?.items;
|
const file = extractPastedImageFile(e.clipboardData);
|
||||||
if (!items) return;
|
if (!file) return;
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
const imageError = validateImageFile(file);
|
||||||
if (items[i].type.indexOf('image') !== -1) {
|
if (imageError) {
|
||||||
const file = items[i].getAsFile();
|
toast.error(imageError);
|
||||||
if (file) {
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
toast.error('Image must be less than 10MB');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isReply) {
|
if (isReply) {
|
||||||
setReplyImageBlob(file);
|
setReplyImageBlob(file);
|
||||||
} else {
|
} else {
|
||||||
setImageBlob(file);
|
setImageBlob(file);
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { VideoAsset } from '@/components/video-page/types';
|
||||||
|
|
||||||
|
type BunnyDownloadPreference = 'original' | 'compressed';
|
||||||
|
|
||||||
|
type CreateAssetPayload = {
|
||||||
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
||||||
|
displayName?: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
providerVideoId?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
uploadToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseVideoAssetsParams {
|
||||||
|
videoId: string;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
canUploadAssets: boolean;
|
||||||
|
canDownloadAssets: boolean;
|
||||||
|
guestName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetsListResponse {
|
||||||
|
data?: {
|
||||||
|
assets?: VideoAsset[];
|
||||||
|
pagination?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
hasMore?: boolean;
|
||||||
|
nextOffset?: number | null;
|
||||||
|
};
|
||||||
|
canUploadAssets?: boolean;
|
||||||
|
canDownloadAssets?: boolean;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetCreateResponse {
|
||||||
|
data?: VideoAsset;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSET_PAGE_SIZE = 40;
|
||||||
|
|
||||||
|
export function useVideoAssets({
|
||||||
|
videoId,
|
||||||
|
isAuthenticated,
|
||||||
|
canUploadAssets,
|
||||||
|
canDownloadAssets,
|
||||||
|
guestName,
|
||||||
|
}: UseVideoAssetsParams) {
|
||||||
|
const [assets, setAssets] = useState<VideoAsset[]>([]);
|
||||||
|
const [isLoadingAssets, setIsLoadingAssets] = useState(true);
|
||||||
|
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||||
|
const [activeDeleteAssetId, setActiveDeleteAssetId] = useState<string | null>(null);
|
||||||
|
const [activeDownloadAssetId, setActiveDownloadAssetId] = useState<string | null>(null);
|
||||||
|
const [hasMoreAssets, setHasMoreAssets] = useState(false);
|
||||||
|
const [nextAssetsOffset, setNextAssetsOffset] = useState(0);
|
||||||
|
const [isLoadingMoreAssets, setIsLoadingMoreAssets] = useState(false);
|
||||||
|
|
||||||
|
const fetchAssets = useCallback(async () => {
|
||||||
|
setIsLoadingAssets(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, { cache: 'no-store' });
|
||||||
|
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(payload?.error || 'Failed to fetch assets');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
|
||||||
|
const pagination = payload?.data?.pagination;
|
||||||
|
setAssets(list);
|
||||||
|
setHasMoreAssets(!!pagination?.hasMore);
|
||||||
|
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to fetch assets');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingAssets(false);
|
||||||
|
}
|
||||||
|
}, [videoId]);
|
||||||
|
|
||||||
|
const loadMoreAssets = useCallback(async () => {
|
||||||
|
if (isLoadingMoreAssets || !hasMoreAssets) return;
|
||||||
|
setIsLoadingMoreAssets(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=${nextAssetsOffset}`, { cache: 'no-store' });
|
||||||
|
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(payload?.error || 'Failed to load more assets');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
|
||||||
|
const pagination = payload?.data?.pagination;
|
||||||
|
setAssets((prev) => [...prev, ...list.filter((asset) => !prev.some((existing) => existing.id === asset.id))]);
|
||||||
|
setHasMoreAssets(!!pagination?.hasMore);
|
||||||
|
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load more assets');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMoreAssets(false);
|
||||||
|
}
|
||||||
|
}, [hasMoreAssets, isLoadingMoreAssets, nextAssetsOffset, videoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchAssets();
|
||||||
|
}, [fetchAssets]);
|
||||||
|
|
||||||
|
const createAsset = useCallback(async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
|
||||||
|
if (!canUploadAssets) {
|
||||||
|
toast.error('You do not have permission to upload assets');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreatingAsset(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/videos/${videoId}/assets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
...(isAuthenticated ? {} : { guestName: guestName?.trim() || 'Guest' }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
|
||||||
|
if (!res.ok || !body?.data) {
|
||||||
|
toast.error(body?.error || 'Failed to create asset');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssets((prev) => [body.data!, ...prev]);
|
||||||
|
setNextAssetsOffset((prev) => prev + 1);
|
||||||
|
return body.data;
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to create asset');
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsCreatingAsset(false);
|
||||||
|
}
|
||||||
|
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
|
||||||
|
|
||||||
|
const deleteAsset = useCallback(async (assetId: string) => {
|
||||||
|
setActiveDeleteAssetId(assetId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(payload?.error || 'Failed to delete asset');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
|
||||||
|
setNextAssetsOffset((prev) => Math.max(0, prev - 1));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete asset');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setActiveDeleteAssetId(null);
|
||||||
|
}
|
||||||
|
}, [videoId]);
|
||||||
|
|
||||||
|
const downloadAsset = useCallback(async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
|
||||||
|
if (!canDownloadAssets) {
|
||||||
|
toast.error('Asset downloads require an authenticated account');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (asset.provider === 'YOUTUBE') {
|
||||||
|
toast.error('YouTube assets cannot be downloaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveDownloadAssetId(asset.id);
|
||||||
|
try {
|
||||||
|
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
|
||||||
|
|
||||||
|
if (asset.provider === 'BUNNY') {
|
||||||
|
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, { cache: 'no-store' });
|
||||||
|
const prepareBody = (await prepareRes.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
if (!prepareRes.ok) {
|
||||||
|
toast.error(prepareBody?.error || 'Download is not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadUrl = `${downloadUrl}?source=${preference}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to start download');
|
||||||
|
} finally {
|
||||||
|
setActiveDownloadAssetId(null);
|
||||||
|
}
|
||||||
|
}, [canDownloadAssets, videoId]);
|
||||||
|
|
||||||
|
const getGuestUploadToken = useCallback(async (intent: 'image') => {
|
||||||
|
if (isAuthenticated) return null;
|
||||||
|
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ intent }),
|
||||||
|
});
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| { data?: { token?: string }; error?: string }
|
||||||
|
| null;
|
||||||
|
const token = payload?.data?.token;
|
||||||
|
if (!response.ok || !token) {
|
||||||
|
throw new Error(payload?.error || 'Failed to prepare upload');
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}, [isAuthenticated, videoId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assets,
|
||||||
|
isLoadingAssets,
|
||||||
|
isCreatingAsset,
|
||||||
|
activeDeleteAssetId,
|
||||||
|
activeDownloadAssetId,
|
||||||
|
hasMoreAssets,
|
||||||
|
isLoadingMoreAssets,
|
||||||
|
fetchAssets,
|
||||||
|
loadMoreAssets,
|
||||||
|
createAsset,
|
||||||
|
deleteAsset,
|
||||||
|
downloadAsset,
|
||||||
|
getGuestUploadToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -521,6 +521,10 @@ export function useVideoPlayer({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (document.querySelector('[data-slot="dialog-content"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -9,24 +9,45 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|||||||
interface ImagePreviewDialogProps {
|
interface ImagePreviewDialogProps {
|
||||||
previewImage: string | null;
|
previewImage: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
title?: string | null;
|
||||||
|
downloadFileName?: string | null;
|
||||||
|
canDownload?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImagePreviewDialog = memo(function ImagePreviewDialog({
|
export const ImagePreviewDialog = memo(function ImagePreviewDialog({
|
||||||
previewImage,
|
previewImage,
|
||||||
onClose,
|
onClose,
|
||||||
|
title,
|
||||||
|
downloadFileName,
|
||||||
|
canDownload = true,
|
||||||
}: ImagePreviewDialogProps) {
|
}: ImagePreviewDialogProps) {
|
||||||
|
const resolvedDownloadName = downloadFileName || (previewImage ? previewImage.split('/').pop() || 'attachment.png' : 'attachment.png');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!previewImage} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={!!previewImage} onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex flex-col items-center justify-center rounded-none"
|
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex items-center justify-center rounded-none"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">Image Preview</DialogTitle>
|
<DialogTitle className="sr-only">{title || 'Image Preview'}</DialogTitle>
|
||||||
<div className="absolute top-4 right-4 flex gap-3 z-50">
|
<div className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
|
||||||
|
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={title || undefined}>
|
||||||
|
{title || 'Image Preview'}
|
||||||
|
</p>
|
||||||
|
{canDownload ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
className="h-8 w-8 shrink-0"
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
@@ -36,7 +57,7 @@ export const ImagePreviewDialog = memo(function ImagePreviewDialog({
|
|||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = previewImage.split('/').pop() || 'attachment.png';
|
a.download = resolvedDownloadName;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
@@ -47,34 +68,36 @@ export const ImagePreviewDialog = memo(function ImagePreviewDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download className="h-5 w-5" />
|
<Download className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
className="h-8 w-8 shrink-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="relative w-full h-full flex items-center justify-center p-4 cursor-zoom-out"
|
className="relative flex-1 min-h-0 w-full flex items-center justify-center p-2 sm:p-4 cursor-zoom-out"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={previewImage}
|
src={previewImage}
|
||||||
alt="Preview"
|
alt={title || 'Preview'}
|
||||||
className="max-w-[95vw] max-h-[90vh] object-contain rounded-md select-none cursor-default"
|
className="max-w-full max-h-full object-contain rounded-md select-none cursor-default"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function validateImageFile(file: File): string | null {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
return 'Please select an image file';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||||
|
return 'Image must be less than 10MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPastedImageFile(data: DataTransfer | null | undefined): File | null {
|
||||||
|
const items = data?.items;
|
||||||
|
if (!items) return null;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (!item.type.startsWith('image/')) continue;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { VideoAsset } from '@/components/video-page/types';
|
||||||
|
|
||||||
|
type MentionRange = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MentionTextareaProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
assets: VideoAsset[];
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
className?: string;
|
||||||
|
onPaste?: React.ClipboardEventHandler<HTMLTextAreaElement>;
|
||||||
|
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMentionRange(text: string, caret: number): MentionRange | null {
|
||||||
|
const before = text.slice(0, caret);
|
||||||
|
const atIndex = before.lastIndexOf('@');
|
||||||
|
if (atIndex < 0) return null;
|
||||||
|
|
||||||
|
const charBeforeAt = atIndex === 0 ? ' ' : before[atIndex - 1];
|
||||||
|
if (charBeforeAt && !/\s/.test(charBeforeAt)) return null;
|
||||||
|
|
||||||
|
const query = before.slice(atIndex + 1);
|
||||||
|
if (query.length === 0) {
|
||||||
|
return { start: atIndex, end: caret, query: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\s|\[|\]|\(|\)/.test(query)) return null;
|
||||||
|
|
||||||
|
return { start: atIndex, end: caret, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentionTextarea({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
assets,
|
||||||
|
placeholder,
|
||||||
|
rows = 2,
|
||||||
|
className,
|
||||||
|
onPaste,
|
||||||
|
onKeyDown,
|
||||||
|
autoFocus,
|
||||||
|
disabled,
|
||||||
|
}: MentionTextareaProps) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const [mentionRange, setMentionRange] = useState<MentionRange | null>(null);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
const searchableAssets = useMemo(() => {
|
||||||
|
return assets
|
||||||
|
.map((asset) => ({
|
||||||
|
asset,
|
||||||
|
displayNameLower: asset.displayName.toLowerCase(),
|
||||||
|
createdAtMs: Date.parse(asset.createdAt),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.createdAtMs - a.createdAtMs);
|
||||||
|
}, [assets]);
|
||||||
|
|
||||||
|
const filteredAssets = useMemo(() => {
|
||||||
|
if (!mentionRange) return [];
|
||||||
|
const query = mentionRange.query.trim().toLowerCase();
|
||||||
|
if (!query) return searchableAssets.slice(0, 8).map((entry) => entry.asset);
|
||||||
|
return searchableAssets
|
||||||
|
.filter((entry) => entry.displayNameLower.includes(query))
|
||||||
|
.map((entry) => entry.asset)
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [mentionRange, searchableAssets]);
|
||||||
|
|
||||||
|
const closeMentions = () => {
|
||||||
|
setMentionRange(null);
|
||||||
|
setActiveIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertAssetMention = (asset: VideoAsset) => {
|
||||||
|
if (!mentionRange || !textareaRef.current) return;
|
||||||
|
const mentionToken = `@[${asset.displayName}](asset:${asset.id}) `;
|
||||||
|
const nextValue = `${value.slice(0, mentionRange.start)}${mentionToken}${value.slice(mentionRange.end)}`;
|
||||||
|
onChange(nextValue);
|
||||||
|
closeMentions();
|
||||||
|
|
||||||
|
const nextCursor = mentionRange.start + mentionToken.length;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!textareaRef.current) return;
|
||||||
|
textareaRef.current.focus();
|
||||||
|
textareaRef.current.setSelectionRange(nextCursor, nextCursor);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
onChange(nextValue);
|
||||||
|
|
||||||
|
const caret = event.target.selectionStart ?? nextValue.length;
|
||||||
|
const range = findMentionRange(nextValue, caret);
|
||||||
|
setMentionRange(range);
|
||||||
|
setActiveIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
|
||||||
|
if (mentionRange && filteredAssets.length > 0) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((prev) => (prev + 1) % filteredAssets.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((prev) => (prev - 1 + filteredAssets.length) % filteredAssets.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
const selected = filteredAssets[Math.min(activeIndex, filteredAssets.length - 1)];
|
||||||
|
if (selected) insertAssetMention(selected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMentions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
className={className}
|
||||||
|
onPaste={onPaste}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
disabled={disabled}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(closeMentions, 120);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mentionRange && filteredAssets.length > 0 && (
|
||||||
|
<div className="absolute left-0 right-0 bottom-full mb-1 z-30 rounded-md border bg-popover shadow-md overflow-hidden">
|
||||||
|
{filteredAssets.map((asset, index) => (
|
||||||
|
<button
|
||||||
|
key={asset.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-2 py-1.5 text-xs hover:bg-accent transition-colors',
|
||||||
|
index === activeIndex && 'bg-accent'
|
||||||
|
)}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
insertAssetMention(asset);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-medium">@{asset.displayName}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">{asset.provider}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,23 @@ export interface CommentTag {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VideoAsset {
|
||||||
|
id: string;
|
||||||
|
videoId: string;
|
||||||
|
kind: 'IMAGE' | 'VIDEO';
|
||||||
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
||||||
|
displayName: string;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
providerVideoId: string | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
uploadedByUserId: string | null;
|
||||||
|
uploadedByGuestName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
|
||||||
|
canDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApprovalDecision {
|
export interface ApprovalDecision {
|
||||||
id: string;
|
id: string;
|
||||||
approverId: string;
|
approverId: string;
|
||||||
@@ -111,6 +128,9 @@ export interface VideoData {
|
|||||||
canManageTags?: boolean;
|
canManageTags?: boolean;
|
||||||
canResolveComments?: boolean;
|
canResolveComments?: boolean;
|
||||||
canRequestApproval?: boolean;
|
canRequestApproval?: boolean;
|
||||||
|
canShareVideo?: boolean;
|
||||||
|
canUploadAssets?: boolean;
|
||||||
|
canDownloadAssets?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BunnyQualityOption {
|
export interface BunnyQualityOption {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ interface VideoPageHeaderProps {
|
|||||||
onCreateVersion: () => void;
|
onCreateVersion: () => void;
|
||||||
onOpenCompare: () => void;
|
onOpenCompare: () => void;
|
||||||
canRequestApproval: boolean;
|
canRequestApproval: boolean;
|
||||||
|
canShareVideo: boolean;
|
||||||
hasPendingApprovalRequest: boolean;
|
hasPendingApprovalRequest: boolean;
|
||||||
onOpenApprovalRequest: () => void;
|
onOpenApprovalRequest: () => void;
|
||||||
onOpenApprovalsPanel: () => void;
|
onOpenApprovalsPanel: () => void;
|
||||||
@@ -107,6 +108,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
onCreateVersion,
|
onCreateVersion,
|
||||||
onOpenCompare,
|
onOpenCompare,
|
||||||
canRequestApproval,
|
canRequestApproval,
|
||||||
|
canShareVideo,
|
||||||
hasPendingApprovalRequest,
|
hasPendingApprovalRequest,
|
||||||
onOpenApprovalRequest,
|
onOpenApprovalRequest,
|
||||||
onOpenApprovalsPanel,
|
onOpenApprovalsPanel,
|
||||||
@@ -231,17 +233,24 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
<div>
|
<div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
<Button variant="outline" size="sm" className="w-7 px-0 self-center">
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
{canShareVideo ? (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
|
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
|
||||||
<Share2 className="h-4 w-4 mr-2" />
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
Share Video
|
Share Video
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
Share Video
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={onOpenApprovalRequest}
|
onSelect={onOpenApprovalRequest}
|
||||||
disabled={!canRequestApproval}
|
disabled={!canRequestApproval}
|
||||||
|
|||||||
+56
-4
@@ -194,7 +194,8 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
const bunnyStats = await getCachedBunnyStorageStats();
|
const bunnyStats = await getCachedBunnyStorageStats();
|
||||||
if (bunnyStats.totalBytes < 0) return perUserStorage;
|
if (bunnyStats.totalBytes < 0) return perUserStorage;
|
||||||
|
|
||||||
const bunnyVersions = await db.videoVersion.findMany({
|
const [bunnyVersions, bunnyAssets] = await Promise.all([
|
||||||
|
db.videoVersion.findMany({
|
||||||
where: { providerId: 'bunny' },
|
where: { providerId: 'bunny' },
|
||||||
select: {
|
select: {
|
||||||
videoId: true,
|
videoId: true,
|
||||||
@@ -206,7 +207,18 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: {
|
||||||
|
provider: 'BUNNY',
|
||||||
|
providerVideoId: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
providerVideoId: true,
|
||||||
|
billedUserId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const seenVideoIds = new Set<string>();
|
const seenVideoIds = new Set<string>();
|
||||||
for (const version of bunnyVersions) {
|
for (const version of bunnyVersions) {
|
||||||
@@ -218,6 +230,17 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
const size = bunnyStats.byVideoId[version.videoId] || 0;
|
const size = bunnyStats.byVideoId[version.videoId] || 0;
|
||||||
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
|
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const asset of bunnyAssets) {
|
||||||
|
if (!asset.providerVideoId) continue;
|
||||||
|
const billedUserId = asset.billedUserId;
|
||||||
|
const dedupeKey = `${billedUserId}:${asset.providerVideoId}`;
|
||||||
|
if (seenVideoIds.has(dedupeKey)) continue;
|
||||||
|
seenVideoIds.add(dedupeKey);
|
||||||
|
|
||||||
|
const size = bunnyStats.byVideoId[asset.providerVideoId] || 0;
|
||||||
|
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to calculate per-user Bunny storage:', err);
|
console.error('Failed to calculate per-user Bunny storage:', err);
|
||||||
}
|
}
|
||||||
@@ -234,7 +257,8 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
|||||||
const snapshot = await getR2StorageSnapshot();
|
const snapshot = await getR2StorageSnapshot();
|
||||||
const seenKeys = new Set<string>();
|
const seenKeys = new Set<string>();
|
||||||
|
|
||||||
const mediaComments = await db.comment.findMany({
|
const [mediaComments, mediaAssets] = await Promise.all([
|
||||||
|
db.comment.findMany({
|
||||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
||||||
select: {
|
select: {
|
||||||
voiceUrl: true,
|
voiceUrl: true,
|
||||||
@@ -255,7 +279,15 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: { provider: 'R2_IMAGE' },
|
||||||
|
select: {
|
||||||
|
sourceUrl: true,
|
||||||
|
billedUserId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
for (const comment of mediaComments) {
|
for (const comment of mediaComments) {
|
||||||
const billedUserId = comment.version.video.project.workspace.ownerId;
|
const billedUserId = comment.version.video.project.workspace.ownerId;
|
||||||
@@ -291,6 +323,26 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const asset of mediaAssets) {
|
||||||
|
const billedUserId = asset.billedUserId;
|
||||||
|
if (!billedUserId) continue;
|
||||||
|
if (!userStorage[billedUserId]) {
|
||||||
|
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyParts = asset.sourceUrl.split('/');
|
||||||
|
const filename = keyParts[keyParts.length - 1];
|
||||||
|
if (!filename) continue;
|
||||||
|
const r2Key = `images/${filename}`;
|
||||||
|
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||||
|
if (seenKeys.has(dedupeKey)) continue;
|
||||||
|
seenKeys.add(dedupeKey);
|
||||||
|
|
||||||
|
const size = snapshot.fileSizes.get(r2Key) || 0;
|
||||||
|
userStorage[billedUserId].image += size;
|
||||||
|
userStorage[billedUserId].total += size;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse user storage:', err);
|
console.error('Failed to parse user storage:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
|
||||||
|
|
||||||
|
export type BunnyDownloadSource = {
|
||||||
|
sourceType: 'original' | 'compressed';
|
||||||
|
quality: number | null;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_BUNNY_CDN_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||||
|
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;
|
||||||
|
|
||||||
|
type BunnyDownloadSourceCacheRecord = {
|
||||||
|
source: BunnyDownloadSource | null;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bunnyDownloadSourceCache = new Map<string, BunnyDownloadSourceCacheRecord>();
|
||||||
|
|
||||||
|
export function resolveBunnyCdnHostname(): string {
|
||||||
|
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
||||||
|
if (!raw) return DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
return parsed.hostname || DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||||
|
} catch {
|
||||||
|
return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), BUNNY_REMOTE_FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isRemoteFileAvailable(url: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const headRes = await fetchWithTimeout(url, { method: 'HEAD', cache: 'no-store' });
|
||||||
|
if (headRes.ok) return true;
|
||||||
|
|
||||||
|
if (headRes.status === 405) {
|
||||||
|
const rangeRes = await fetchWithTimeout(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Range: 'bytes=0-0' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
return rangeRes.ok || rangeRes.status === 206;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBunnyOriginalUrl(videoId: string): string {
|
||||||
|
return `https://${resolveBunnyCdnHostname()}/${videoId}/original`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHeightFromBunnyMp4Url(url: string): number | null {
|
||||||
|
const match = url.match(/\/play_(\d+)p\.mp4$/);
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
const parsed = Number(match[1]);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
||||||
|
const hostname = resolveBunnyCdnHostname();
|
||||||
|
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
|
||||||
|
|
||||||
|
let playlistHeights: number[] = [];
|
||||||
|
try {
|
||||||
|
const playlistRes = await fetchWithTimeout(playlistUrl, { cache: 'no-store' });
|
||||||
|
if (playlistRes.ok) {
|
||||||
|
const playlist = await playlistRes.text();
|
||||||
|
const matches = [...playlist.matchAll(/RESOLUTION=\d+x(\d+)/g)];
|
||||||
|
playlistHeights = matches
|
||||||
|
.map((match) => Number(match[1]))
|
||||||
|
.filter((height) => Number.isFinite(height) && BUNNY_ALLOWED_QUALITIES.has(height))
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to static fallback list.
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])]
|
||||||
|
.slice(0, BUNNY_MAX_PROBE_CANDIDATES);
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
|
||||||
|
const originalUrl = buildBunnyOriginalUrl(videoId);
|
||||||
|
if (await isRemoteFileAvailable(originalUrl)) {
|
||||||
|
return {
|
||||||
|
sourceType: 'original',
|
||||||
|
quality: null,
|
||||||
|
url: originalUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
|
||||||
|
if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) {
|
||||||
|
const requestedUrl = `https://${resolveBunnyCdnHostname()}/${videoId}/play_${requestedQuality}p.mp4`;
|
||||||
|
if (await isRemoteFileAvailable(requestedUrl)) {
|
||||||
|
return {
|
||||||
|
sourceType: 'compressed',
|
||||||
|
quality: extractHeightFromBunnyMp4Url(requestedUrl),
|
||||||
|
url: requestedUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
|
||||||
|
return {
|
||||||
|
sourceType: 'compressed',
|
||||||
|
quality: extractHeightFromBunnyMp4Url(fallbackUrl),
|
||||||
|
url: fallbackUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSourceCacheKey(videoId: string, requestedQuality: number | null, preference: BunnyDownloadSourcePreference): string {
|
||||||
|
return `${videoId}:${requestedQuality ?? 'none'}:${preference}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedSource(cacheKey: string, now: number): BunnyDownloadSource | null | undefined {
|
||||||
|
const cached = bunnyDownloadSourceCache.get(cacheKey);
|
||||||
|
if (!cached) return undefined;
|
||||||
|
if (cached.expiresAt <= now) {
|
||||||
|
bunnyDownloadSourceCache.delete(cacheKey);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cached.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedSource(cacheKey: string, source: BunnyDownloadSource | null, now: number): void {
|
||||||
|
bunnyDownloadSourceCache.set(cacheKey, {
|
||||||
|
source,
|
||||||
|
expiresAt: now + BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBunnyDownloadSource(
|
||||||
|
videoId: string,
|
||||||
|
requestedQuality: number | null,
|
||||||
|
preference: BunnyDownloadSourcePreference
|
||||||
|
): Promise<BunnyDownloadSource | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
const cacheKey = buildSourceCacheKey(videoId, requestedQuality, preference);
|
||||||
|
const cached = getCachedSource(cacheKey, now);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
let resolvedSource: BunnyDownloadSource | null;
|
||||||
|
|
||||||
|
if (preference === 'original') {
|
||||||
|
resolvedSource = await resolveBunnyOriginalSource(videoId);
|
||||||
|
setCachedSource(cacheKey, resolvedSource, now);
|
||||||
|
return resolvedSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preference === 'compressed') {
|
||||||
|
resolvedSource = await resolveBunnyCompressedSource(videoId, requestedQuality);
|
||||||
|
setCachedSource(cacheKey, resolvedSource, now);
|
||||||
|
return resolvedSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSource = await resolveBunnyOriginalSource(videoId);
|
||||||
|
if (originalSource) {
|
||||||
|
setCachedSource(cacheKey, originalSource, now);
|
||||||
|
return originalSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedSource = await resolveBunnyCompressedSource(videoId, requestedQuality);
|
||||||
|
setCachedSource(cacheKey, resolvedSource, now);
|
||||||
|
return resolvedSource;
|
||||||
|
}
|
||||||
@@ -6,10 +6,11 @@ const GUEST_UPLOAD_TOKEN_TYPE = 'guest-upload';
|
|||||||
const DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS = 60 * 3;
|
const DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS = 60 * 3;
|
||||||
const GUEST_UPLOAD_VIDEO_WINDOW_MS = 15 * 60 * 1000;
|
const GUEST_UPLOAD_VIDEO_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const GUEST_UPLOAD_VIDEO_MAX_REQUESTS = 12;
|
const GUEST_UPLOAD_VIDEO_MAX_REQUESTS = 12;
|
||||||
|
const GUEST_BUNNY_UPLOAD_VIDEO_MAX_REQUESTS = 4;
|
||||||
const GUEST_UPLOAD_SESSION_WINDOW_MS = 15 * 60 * 1000;
|
const GUEST_UPLOAD_SESSION_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const GUEST_UPLOAD_SESSION_MAX_REQUESTS = 8;
|
const GUEST_UPLOAD_SESSION_MAX_REQUESTS = 8;
|
||||||
|
|
||||||
export type GuestUploadIntent = 'audio' | 'image';
|
export type GuestUploadIntent = 'audio' | 'image' | 'bunny';
|
||||||
|
|
||||||
interface GuestUploadTokenPayload {
|
interface GuestUploadTokenPayload {
|
||||||
typ: typeof GUEST_UPLOAD_TOKEN_TYPE;
|
typ: typeof GUEST_UPLOAD_TOKEN_TYPE;
|
||||||
@@ -71,7 +72,7 @@ function isValidPayload(value: unknown): value is GuestUploadTokenPayload {
|
|||||||
&& Number.isFinite(payload.iat)
|
&& Number.isFinite(payload.iat)
|
||||||
&& typeof payload.exp === 'number'
|
&& typeof payload.exp === 'number'
|
||||||
&& Number.isFinite(payload.exp)
|
&& Number.isFinite(payload.exp)
|
||||||
&& (payload.intent === 'audio' || payload.intent === 'image')
|
&& (payload.intent === 'audio' || payload.intent === 'image' || payload.intent === 'bunny')
|
||||||
&& typeof payload.ctx === 'string';
|
&& typeof payload.ctx === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,17 +151,21 @@ export async function enforceGuestUploadQuota(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const videoScopedMaxRequests = intent === 'bunny'
|
||||||
|
? GUEST_BUNNY_UPLOAD_VIDEO_MAX_REQUESTS
|
||||||
|
: GUEST_UPLOAD_VIDEO_MAX_REQUESTS;
|
||||||
|
|
||||||
const videoScoped = await checkRateLimit(
|
const videoScoped = await checkRateLimit(
|
||||||
`${ip}:guest-upload:${intent}:video:${videoId}`,
|
`${ip}:guest-upload:${intent}:video:${videoId}`,
|
||||||
`guest-upload-${intent}-video`,
|
`guest-upload-${intent}-video`,
|
||||||
{ windowMs: GUEST_UPLOAD_VIDEO_WINDOW_MS, maxRequests: GUEST_UPLOAD_VIDEO_MAX_REQUESTS }
|
{ windowMs: GUEST_UPLOAD_VIDEO_WINDOW_MS, maxRequests: videoScopedMaxRequests }
|
||||||
);
|
);
|
||||||
if (!videoScoped.allowed) {
|
if (!videoScoped.allowed) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Too many uploads for this video. Please wait before uploading again.' },
|
{ error: 'Too many uploads for this video. Please wait before uploading again.' },
|
||||||
{
|
{
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: rateLimitHeaders(videoScoped, GUEST_UPLOAD_VIDEO_MAX_REQUESTS),
|
headers: rateLimitHeaders(videoScoped, videoScopedMaxRequests),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-6
@@ -45,18 +45,30 @@ async function deleteMediaFiles(mediaUrls: string[]) {
|
|||||||
* Collect all media URLs from comments under a given video (all versions).
|
* Collect all media URLs from comments under a given video (all versions).
|
||||||
*/
|
*/
|
||||||
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
|
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
|
||||||
const comments = await db.comment.findMany({
|
const [comments, assets] = await Promise.all([
|
||||||
|
db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||||
version: { videoParentId: videoId },
|
version: { videoParentId: videoId },
|
||||||
},
|
},
|
||||||
select: { voiceUrl: true, imageUrl: true },
|
select: { voiceUrl: true, imageUrl: true },
|
||||||
});
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: {
|
||||||
|
videoId,
|
||||||
|
provider: 'R2_IMAGE',
|
||||||
|
},
|
||||||
|
select: { sourceUrl: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
comments.forEach(c => {
|
comments.forEach(c => {
|
||||||
if (c.voiceUrl) urls.push(c.voiceUrl);
|
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||||
if (c.imageUrl) urls.push(c.imageUrl);
|
if (c.imageUrl) urls.push(c.imageUrl);
|
||||||
});
|
});
|
||||||
|
assets.forEach((asset) => {
|
||||||
|
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||||
|
});
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,18 +76,30 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
|||||||
* Collect all media URLs from comments under all videos in a project.
|
* Collect all media URLs from comments under all videos in a project.
|
||||||
*/
|
*/
|
||||||
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
|
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
|
||||||
const comments = await db.comment.findMany({
|
const [comments, assets] = await Promise.all([
|
||||||
|
db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||||
version: { video: { projectId } },
|
version: { video: { projectId } },
|
||||||
},
|
},
|
||||||
select: { voiceUrl: true, imageUrl: true },
|
select: { voiceUrl: true, imageUrl: true },
|
||||||
});
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: {
|
||||||
|
provider: 'R2_IMAGE',
|
||||||
|
video: { projectId },
|
||||||
|
},
|
||||||
|
select: { sourceUrl: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
comments.forEach(c => {
|
comments.forEach(c => {
|
||||||
if (c.voiceUrl) urls.push(c.voiceUrl);
|
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||||
if (c.imageUrl) urls.push(c.imageUrl);
|
if (c.imageUrl) urls.push(c.imageUrl);
|
||||||
});
|
});
|
||||||
|
assets.forEach((asset) => {
|
||||||
|
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||||
|
});
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,18 +107,30 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
|||||||
* Collect all media URLs from comments under all projects in a workspace.
|
* Collect all media URLs from comments under all projects in a workspace.
|
||||||
*/
|
*/
|
||||||
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
|
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
|
||||||
const comments = await db.comment.findMany({
|
const [comments, assets] = await Promise.all([
|
||||||
|
db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||||
version: { video: { project: { workspaceId } } },
|
version: { video: { project: { workspaceId } } },
|
||||||
},
|
},
|
||||||
select: { voiceUrl: true, imageUrl: true },
|
select: { voiceUrl: true, imageUrl: true },
|
||||||
});
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: {
|
||||||
|
provider: 'R2_IMAGE',
|
||||||
|
video: { project: { workspaceId } },
|
||||||
|
},
|
||||||
|
select: { sourceUrl: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
comments.forEach(c => {
|
comments.forEach(c => {
|
||||||
if (c.voiceUrl) urls.push(c.voiceUrl);
|
if (c.voiceUrl) urls.push(c.voiceUrl);
|
||||||
if (c.imageUrl) urls.push(c.imageUrl);
|
if (c.imageUrl) urls.push(c.imageUrl);
|
||||||
});
|
});
|
||||||
|
assets.forEach((asset) => {
|
||||||
|
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||||
|
});
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
|||||||
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||||
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||||
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
|
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
|
||||||
|
'asset-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
|
||||||
|
'asset-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||||
|
'asset-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||||
|
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||||
|
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||||
|
|
||||||
// Watch progress — allow frequent updates but prevent abuse
|
// Watch progress — allow frequent updates but prevent abuse
|
||||||
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)
|
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import type { VideoAsset } from '@prisma/client';
|
||||||
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||||
|
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||||
|
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||||
|
|
||||||
|
const IMAGE_PROXY_PREFIX = '/api/upload/image/';
|
||||||
|
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
||||||
|
|
||||||
|
export const SAFE_IMAGE_PROXY_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||||
|
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
||||||
|
|
||||||
|
export type VideoAssetAccessContext = {
|
||||||
|
video: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
projectId: string;
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ownerId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
visibility: string;
|
||||||
|
workspace: {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
hasViewAccess: boolean;
|
||||||
|
canUploadAssets: boolean;
|
||||||
|
canDownloadAssets: boolean;
|
||||||
|
canManageAssets: boolean;
|
||||||
|
viewerUserId: string | null;
|
||||||
|
viewerGuestIdentityId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sanitizeAssetDisplayName(value: string | null | undefined, fallback: string): string {
|
||||||
|
const raw = typeof value === 'string' ? value : '';
|
||||||
|
const normalized = raw
|
||||||
|
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||||
|
.replace(/[\[\]\(\)]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (normalized.length === 0) return fallback;
|
||||||
|
return normalized.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractImageKeyFromProxyUrl(url: string): string | null {
|
||||||
|
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
|
||||||
|
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
|
||||||
|
if (!filename) return null;
|
||||||
|
return `images/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractImageFileNameFromProxyUrl(url: string): string | null {
|
||||||
|
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
|
||||||
|
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
|
||||||
|
return filename || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaUrlToR2Key(url: string): string | null {
|
||||||
|
if (url.includes(IMAGE_PROXY_PREFIX)) {
|
||||||
|
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
|
||||||
|
return filename ? `images/${filename}` : null;
|
||||||
|
}
|
||||||
|
if (url.includes(AUDIO_PROXY_PREFIX)) {
|
||||||
|
const filename = url.slice(url.indexOf(AUDIO_PROXY_PREFIX) + AUDIO_PROXY_PREFIX.length);
|
||||||
|
return filename ? `voice/${filename}` : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canDeleteAssetForViewer(
|
||||||
|
asset: Pick<VideoAsset, 'uploadedByUserId' | 'uploadedByGuestIdentityId'>,
|
||||||
|
viewer: Pick<VideoAssetAccessContext, 'canManageAssets' | 'viewerUserId' | 'viewerGuestIdentityId'>
|
||||||
|
): boolean {
|
||||||
|
if (viewer.canManageAssets) return true;
|
||||||
|
if (viewer.viewerUserId && asset.uploadedByUserId === viewer.viewerUserId) return true;
|
||||||
|
if (
|
||||||
|
!viewer.viewerUserId
|
||||||
|
&& viewer.viewerGuestIdentityId
|
||||||
|
&& asset.uploadedByGuestIdentityId
|
||||||
|
&& asset.uploadedByGuestIdentityId === viewer.viewerGuestIdentityId
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVideoAssetAccessContext(
|
||||||
|
request: NextRequest,
|
||||||
|
videoId: string,
|
||||||
|
requiredPermission: 'VIEW' | 'COMMENT' = 'VIEW'
|
||||||
|
): Promise<VideoAssetAccessContext | null> {
|
||||||
|
const session = await auth();
|
||||||
|
const video = await db.video.findUnique({
|
||||||
|
where: { id: videoId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
projectId: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ownerId: true,
|
||||||
|
workspaceId: true,
|
||||||
|
visibility: true,
|
||||||
|
workspace: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ownerId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) return null;
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||||
|
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||||
|
const shareAccess = shareSession
|
||||||
|
? await validateShareLinkAccess({
|
||||||
|
token: shareSession.token,
|
||||||
|
projectId: video.projectId,
|
||||||
|
videoId: video.id,
|
||||||
|
requiredPermission,
|
||||||
|
passwordVerified: shareSession.passwordVerified,
|
||||||
|
})
|
||||||
|
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link: null };
|
||||||
|
|
||||||
|
const hasViewAccess = access.hasAccess || shareAccess.hasAccess;
|
||||||
|
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
||||||
|
const canCommentWithShare = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||||
|
const canUploadAssets = canCommentWithMembership || canCommentWithShare;
|
||||||
|
const canDownloadAssets = !!session?.user?.id && hasViewAccess;
|
||||||
|
|
||||||
|
const viewerUserId = session?.user?.id ?? null;
|
||||||
|
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
|
||||||
|
|
||||||
|
return {
|
||||||
|
video,
|
||||||
|
hasViewAccess,
|
||||||
|
canUploadAssets,
|
||||||
|
canDownloadAssets,
|
||||||
|
canManageAssets: access.canEdit,
|
||||||
|
viewerUserId,
|
||||||
|
viewerGuestIdentityId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ model User {
|
|||||||
workspaceMemberships WorkspaceMember[]
|
workspaceMemberships WorkspaceMember[]
|
||||||
projects Project[]
|
projects Project[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy")
|
||||||
|
billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo")
|
||||||
projectMemberships ProjectMember[]
|
projectMemberships ProjectMember[]
|
||||||
notificationSetting NotificationSetting?
|
notificationSetting NotificationSetting?
|
||||||
watchProgress WatchProgress[]
|
watchProgress WatchProgress[]
|
||||||
@@ -65,6 +67,17 @@ enum DownloadEgressSource {
|
|||||||
COMPRESSED
|
COMPRESSED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VideoAssetKind {
|
||||||
|
IMAGE
|
||||||
|
VIDEO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VideoAssetProvider {
|
||||||
|
R2_IMAGE
|
||||||
|
YOUTUBE
|
||||||
|
BUNNY
|
||||||
|
}
|
||||||
|
|
||||||
model DownloadEgressEvent {
|
model DownloadEgressEvent {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
versionId String
|
versionId String
|
||||||
@@ -303,6 +316,7 @@ model Video {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
versions VideoVersion[]
|
versions VideoVersion[]
|
||||||
|
assets VideoAsset[]
|
||||||
shareLinks ShareLink[]
|
shareLinks ShareLink[]
|
||||||
|
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@ -345,6 +359,33 @@ model VideoVersion {
|
|||||||
@@map("video_versions")
|
@@map("video_versions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model VideoAsset {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
videoId String
|
||||||
|
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||||
|
kind VideoAssetKind
|
||||||
|
provider VideoAssetProvider
|
||||||
|
displayName String
|
||||||
|
sourceUrl String
|
||||||
|
providerVideoId String?
|
||||||
|
thumbnailUrl String?
|
||||||
|
uploadedByUserId String?
|
||||||
|
uploadedByUser User? @relation("VideoAssetUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
|
||||||
|
uploadedByGuestIdentityId String?
|
||||||
|
uploadedByGuestName String?
|
||||||
|
billedUserId String
|
||||||
|
billedUser User @relation("VideoAssetBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([videoId])
|
||||||
|
@@index([billedUserId])
|
||||||
|
@@index([provider])
|
||||||
|
@@index([providerVideoId])
|
||||||
|
@@index([videoId, createdAt(sort: Desc)])
|
||||||
|
@@map("video_assets")
|
||||||
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
|||||||
@@ -142,16 +142,28 @@ async function findReferencedVideoIds(videoIds: string[]): Promise<Set<string>>
|
|||||||
const referenced = new Set<string>();
|
const referenced = new Set<string>();
|
||||||
|
|
||||||
for (const group of chunk(videoIds, CHUNK_SIZE)) {
|
for (const group of chunk(videoIds, CHUNK_SIZE)) {
|
||||||
const rows = await db.videoVersion.findMany({
|
const [versionRows, assetRows] = await Promise.all([
|
||||||
|
db.videoVersion.findMany({
|
||||||
where: {
|
where: {
|
||||||
providerId: 'bunny',
|
providerId: 'bunny',
|
||||||
videoId: { in: group },
|
videoId: { in: group },
|
||||||
},
|
},
|
||||||
select: { videoId: true },
|
select: { videoId: true },
|
||||||
});
|
}),
|
||||||
rows.forEach((row) => {
|
db.videoAsset.findMany({
|
||||||
|
where: {
|
||||||
|
provider: 'BUNNY',
|
||||||
|
providerVideoId: { in: group },
|
||||||
|
},
|
||||||
|
select: { providerVideoId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
versionRows.forEach((row) => {
|
||||||
if (row.videoId) referenced.add(row.videoId);
|
if (row.videoId) referenced.add(row.videoId);
|
||||||
});
|
});
|
||||||
|
assetRows.forEach((row) => {
|
||||||
|
if (row.providerVideoId) referenced.add(row.providerVideoId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return referenced;
|
return referenced;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
|
|||||||
}).userFeedbackScreenshot;
|
}).userFeedbackScreenshot;
|
||||||
|
|
||||||
for (const group of chunk(urls, CHUNK_SIZE)) {
|
for (const group of chunk(urls, CHUNK_SIZE)) {
|
||||||
const [commentRows, feedbackRows, feedbackAttachmentRows] = await Promise.all([
|
const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows] = await Promise.all([
|
||||||
db.comment.findMany({
|
db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
|
OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
|
||||||
@@ -99,6 +99,10 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
|
|||||||
select: { url: true },
|
select: { url: true },
|
||||||
})
|
})
|
||||||
: Promise.resolve([] as Array<{ url: string }>),
|
: Promise.resolve([] as Array<{ url: string }>),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: { sourceUrl: { in: group } },
|
||||||
|
select: { sourceUrl: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const row of commentRows) {
|
for (const row of commentRows) {
|
||||||
@@ -111,6 +115,9 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
|
|||||||
for (const row of feedbackAttachmentRows) {
|
for (const row of feedbackAttachmentRows) {
|
||||||
if (row.url) referenced.add(row.url);
|
if (row.url) referenced.add(row.url);
|
||||||
}
|
}
|
||||||
|
for (const row of assetRows) {
|
||||||
|
if (row.sourceUrl) referenced.add(row.sourceUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return referenced;
|
return referenced;
|
||||||
|
|||||||
Reference in New Issue
Block a user