mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: bulk video uploads and S3 asset video support (#18)
Add multi-file drag-and-drop queues for project videos and the assets pane, and route asset video uploads through S3/R2 when direct Bunny uploads are disabled.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, UploadCloud } from 'lucide-react';
|
||||
import { CheckCircle2, Loader2, UploadCloud, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -22,7 +22,14 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
import {
|
||||
cleanupPendingProjectUpload,
|
||||
getDefaultTitleFromFile,
|
||||
isVideoFile,
|
||||
uploadProjectVideo,
|
||||
type ActiveTusUpload,
|
||||
type PendingProjectUploadCleanup,
|
||||
} from '@/lib/client/project-video-upload';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
|
||||
type ProjectOption = {
|
||||
@@ -31,6 +38,16 @@ type ProjectOption = {
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
type QueueItemStatus = 'pending' | 'uploading' | 'done' | 'error' | 'cancelled';
|
||||
|
||||
type QueueItem = {
|
||||
id: string;
|
||||
file: File;
|
||||
status: QueueItemStatus;
|
||||
progress: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
interface VideoDragDropUploaderProps {
|
||||
fixedProjectId?: string;
|
||||
fixedProjectName?: string;
|
||||
@@ -40,29 +57,18 @@ interface VideoDragDropUploaderProps {
|
||||
directUploadProvider?: DirectUploadProvider;
|
||||
}
|
||||
|
||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
||||
type ActiveTusUpload = { abort: (shouldTerminate?: boolean) => Promise<unknown> | void };
|
||||
|
||||
function isVideoFile(file: File): boolean {
|
||||
if (file.type.startsWith('video/')) return true;
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
return !!ext && VIDEO_FILE_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function extractVideoFile(dataTransfer: DataTransfer | null): File | null {
|
||||
if (!dataTransfer?.files?.length) return null;
|
||||
const files = Array.from(dataTransfer.files);
|
||||
return files.find(isVideoFile) ?? null;
|
||||
}
|
||||
|
||||
function hasFileData(dataTransfer: DataTransfer | null): boolean {
|
||||
if (!dataTransfer) return false;
|
||||
return Array.from(dataTransfer.types || []).includes('Files');
|
||||
}
|
||||
|
||||
function getDefaultTitleFromFile(file: File): string {
|
||||
const withoutExt = file.name.replace(/\.[^/.]+$/, '').trim();
|
||||
return withoutExt || file.name;
|
||||
function createQueueItem(file: File): QueueItem {
|
||||
return {
|
||||
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`,
|
||||
file,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function VideoDragDropUploader({
|
||||
@@ -79,7 +85,7 @@ export function VideoDragDropUploader({
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [droppedFile, setDroppedFile] = useState<File | null>(null);
|
||||
const [queue, setQueue] = useState<QueueItem[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
@@ -90,23 +96,9 @@ export function VideoDragDropUploader({
|
||||
);
|
||||
|
||||
const activeTusUploadRef = useRef<ActiveTusUpload | null>(null);
|
||||
const pendingUploadRef = useRef<
|
||||
| {
|
||||
type: 'bunny';
|
||||
projectId: string;
|
||||
videoId: string;
|
||||
uploadToken: string;
|
||||
}
|
||||
| {
|
||||
type: 'r2';
|
||||
projectId: string;
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
thumbnailObjectKey?: string;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
const pendingUploadRef = useRef<(PendingProjectUploadCleanup & { projectId: string }) | null>(
|
||||
null
|
||||
);
|
||||
const cancelRequestedRef = useRef(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
const hasLoadedProjectsRef = useRef(false);
|
||||
@@ -118,6 +110,12 @@ export function VideoDragDropUploader({
|
||||
return new Map(projects.map((project) => [project.id, project.name]));
|
||||
}, [projects]);
|
||||
|
||||
const pendingCount = queue.filter((item) => item.status === 'pending').length;
|
||||
const doneCount = queue.filter((item) => item.status === 'done').length;
|
||||
const errorCount = queue.filter((item) => item.status === 'error').length;
|
||||
const totalCount = queue.length;
|
||||
const hasQueue = totalCount > 0;
|
||||
|
||||
const ensureProjectsLoaded = useCallback(async () => {
|
||||
if (!canUpload) return;
|
||||
if (!needsProjectSelection) return;
|
||||
@@ -183,7 +181,7 @@ export function VideoDragDropUploader({
|
||||
useEffect(() => {
|
||||
if (!canUpload) {
|
||||
setDialogOpen(false);
|
||||
setDroppedFile(null);
|
||||
setQueue([]);
|
||||
}
|
||||
}, [canUpload]);
|
||||
|
||||
@@ -196,7 +194,7 @@ export function VideoDragDropUploader({
|
||||
}
|
||||
}, [needsProjectSelection, projectOptions, workspaceId]);
|
||||
|
||||
const cleanupUploadState = useCallback(() => {
|
||||
const resetUploadState = useCallback(() => {
|
||||
activeTusUploadRef.current = null;
|
||||
pendingUploadRef.current = null;
|
||||
setIsUploading(false);
|
||||
@@ -207,7 +205,6 @@ export function VideoDragDropUploader({
|
||||
const cancelPendingUpload = useCallback(async () => {
|
||||
if (!isUploading) return;
|
||||
cancelRequestedRef.current = true;
|
||||
const pending = pendingUploadRef.current;
|
||||
|
||||
if (activeTusUploadRef.current) {
|
||||
try {
|
||||
@@ -219,274 +216,163 @@ export function VideoDragDropUploader({
|
||||
}
|
||||
}
|
||||
|
||||
const pending = pendingUploadRef.current;
|
||||
if (pending) {
|
||||
try {
|
||||
if (pending.type === 'bunny') {
|
||||
await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
|
||||
});
|
||||
} else {
|
||||
await cleanupPendingR2VideoUpload(pending.projectId, {
|
||||
objectKey: pending.objectKey,
|
||||
uploadToken: pending.uploadToken,
|
||||
reservationId: pending.reservationId,
|
||||
thumbnailObjectKey: pending.thumbnailObjectKey,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup cancelled upload:', error);
|
||||
}
|
||||
await cleanupPendingProjectUpload(pending.projectId, pending);
|
||||
}
|
||||
|
||||
cleanupUploadState();
|
||||
setDroppedFile(null);
|
||||
setDialogOpen(false);
|
||||
setQueue((prev) =>
|
||||
prev.map((item) =>
|
||||
item.status === 'uploading' || item.status === 'pending'
|
||||
? { ...item, status: 'cancelled' as const }
|
||||
: item
|
||||
)
|
||||
);
|
||||
|
||||
resetUploadState();
|
||||
setShowCancelUploadDialog(false);
|
||||
toast.info('Upload cancelled');
|
||||
}, [cleanupUploadState, isUploading]);
|
||||
}, [isUploading, resetUploadState]);
|
||||
|
||||
const uploadQueueToProject = useCallback(
|
||||
async (files: File[], projectId: string, projectName?: string) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const uploadFileToProject = useCallback(
|
||||
async (file: File, projectId: string, projectName?: string) => {
|
||||
setDialogOpen(true);
|
||||
cancelRequestedRef.current = false;
|
||||
setIsUploading(true);
|
||||
setUploadStatus('Initializing upload...');
|
||||
setUploadProgress(0);
|
||||
setSelectedProjectId(projectId);
|
||||
setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null);
|
||||
|
||||
let pendingCleanup:
|
||||
| { type: 'bunny'; videoId: string; uploadToken: string }
|
||||
| {
|
||||
type: 'r2';
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
thumbnailObjectKey?: string;
|
||||
}
|
||||
| null = null;
|
||||
const initialQueue = files.map(createQueueItem);
|
||||
setQueue(initialQueue);
|
||||
|
||||
try {
|
||||
const title = getDefaultTitleFromFile(file);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
if (directUploadProvider === 'r2') {
|
||||
const uploaded = await uploadVideoToR2(projectId, file, {
|
||||
for (let index = 0; index < initialQueue.length; index++) {
|
||||
if (cancelRequestedRef.current) break;
|
||||
|
||||
const item = initialQueue[index];
|
||||
setUploadProgress(0);
|
||||
setUploadStatus(`Uploading ${index + 1} of ${initialQueue.length}: ${item.file.name}`);
|
||||
|
||||
setQueue((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.id === item.id
|
||||
? { ...entry, status: 'uploading', progress: 0, error: undefined }
|
||||
: entry
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await uploadProjectVideo(projectId, item.file, {
|
||||
provider: directUploadProvider,
|
||||
bunnyCdnHostname,
|
||||
onProgress: (progress) => {
|
||||
setUploadProgress(progress);
|
||||
setUploadStatus(`Uploading... ${progress}%`);
|
||||
setQueue((prev) =>
|
||||
prev.map((entry) => (entry.id === item.id ? { ...entry, progress } : entry))
|
||||
);
|
||||
},
|
||||
});
|
||||
pendingCleanup = {
|
||||
type: 'r2',
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
};
|
||||
pendingUploadRef.current = {
|
||||
type: 'r2',
|
||||
projectId,
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
};
|
||||
|
||||
setUploadStatus('Saving video...');
|
||||
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description: null,
|
||||
videoUrl: uploaded.proxyUrl,
|
||||
providerId: 'r2',
|
||||
videoId: uploaded.objectKey,
|
||||
thumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
|
||||
duration: uploaded.duration,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
objectKey: uploaded.objectKey,
|
||||
reservationId: uploaded.reservationId,
|
||||
}),
|
||||
onStatus: (status) => {
|
||||
setUploadStatus(`Uploading ${index + 1} of ${initialQueue.length}: ${status}`);
|
||||
},
|
||||
onTusUploadReady: (upload) => {
|
||||
activeTusUploadRef.current = upload;
|
||||
},
|
||||
onPendingUpload: (pending) => {
|
||||
pendingUploadRef.current = { ...pending, projectId };
|
||||
},
|
||||
isCancelled: () => cancelRequestedRef.current,
|
||||
});
|
||||
|
||||
const createPayload = (await createResponse.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
if (cancelRequestedRef.current) break;
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(createPayload?.error || 'Failed to create video');
|
||||
}
|
||||
pendingUploadRef.current = null;
|
||||
activeTusUploadRef.current = null;
|
||||
successCount += 1;
|
||||
|
||||
toast.success(
|
||||
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
||||
setQueue((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.id === item.id ? { ...entry, status: 'done', progress: 100 } : entry
|
||||
)
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setDroppedFile(null);
|
||||
cleanupUploadState();
|
||||
router.push(`/projects/${projectId}`);
|
||||
router.refresh();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (cancelRequestedRef.current) break;
|
||||
|
||||
pendingUploadRef.current = null;
|
||||
activeTusUploadRef.current = null;
|
||||
failCount += 1;
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Failed to upload video';
|
||||
setQueue((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.id === item.id ? { ...entry, status: 'error', error: message } : entry
|
||||
)
|
||||
);
|
||||
toast.error(`${item.file.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
resetUploadState();
|
||||
|
||||
const initPayload = (await initResponse.json().catch(() => null)) as {
|
||||
data?: {
|
||||
videoId: string;
|
||||
libraryId: string;
|
||||
signature: string;
|
||||
expirationTime: number;
|
||||
uploadToken: string;
|
||||
};
|
||||
error?: string;
|
||||
} | null;
|
||||
if (cancelRequestedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initResponse.ok || !initPayload?.data) {
|
||||
throw new Error(initPayload?.error || 'Failed to initialize upload');
|
||||
}
|
||||
|
||||
const createdVideoId = initPayload.data.videoId;
|
||||
const uploadToken = initPayload.data.uploadToken;
|
||||
pendingCleanup = { type: 'bunny', videoId: createdVideoId, uploadToken };
|
||||
pendingUploadRef.current = {
|
||||
type: 'bunny',
|
||||
projectId,
|
||||
videoId: createdVideoId,
|
||||
uploadToken,
|
||||
};
|
||||
|
||||
const { Upload } = await import('tus-js-client');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const upload = new Upload(file, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: initPayload.data!.signature,
|
||||
AuthorizationExpire: initPayload.data!.expirationTime.toString(),
|
||||
VideoId: initPayload.data!.videoId,
|
||||
LibraryId: initPayload.data!.libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: file.type,
|
||||
title,
|
||||
},
|
||||
onError: (error) => {
|
||||
activeTusUploadRef.current = null;
|
||||
reject(new Error(error.message));
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = Number(((bytesUploaded / bytesTotal) * 100).toFixed(1));
|
||||
setUploadProgress(percentage);
|
||||
setUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
activeTusUploadRef.current = null;
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
|
||||
activeTusUploadRef.current = upload;
|
||||
upload.start();
|
||||
});
|
||||
|
||||
setUploadStatus('Saving video...');
|
||||
|
||||
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description: null,
|
||||
videoUrl: `https://iframe.mediadelivery.net/embed/${initPayload.data.libraryId}/${initPayload.data.videoId}`,
|
||||
providerId: 'bunny',
|
||||
videoId: initPayload.data.videoId,
|
||||
thumbnailUrl: bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${initPayload.data.videoId}/thumbnail.jpg`
|
||||
: null,
|
||||
duration: null,
|
||||
uploadToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const createPayload = (await createResponse.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(createPayload?.error || 'Failed to create video');
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setDroppedFile(null);
|
||||
cleanupUploadState();
|
||||
router.push(`/projects/${projectId}`);
|
||||
if (successCount > 0) {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error('Drag-drop upload failed:', error);
|
||||
}
|
||||
|
||||
if (cancelRequestedRef.current) {
|
||||
setUploadStatus('');
|
||||
setUploadProgress(0);
|
||||
setIsUploading(false);
|
||||
return;
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(
|
||||
successCount === 1
|
||||
? `Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
||||
: `${successCount} videos uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
||||
);
|
||||
if (fixedProjectId) {
|
||||
setDialogOpen(false);
|
||||
setQueue([]);
|
||||
}
|
||||
|
||||
if (pendingCleanup) {
|
||||
try {
|
||||
if (pendingCleanup.type === 'bunny') {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoId: pendingCleanup.videoId,
|
||||
uploadToken: pendingCleanup.uploadToken,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to cleanup pending upload:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadStatus('');
|
||||
setUploadProgress(0);
|
||||
setIsUploading(false);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`${successCount} uploaded, ${failCount} failed`);
|
||||
} else if (failCount > 0) {
|
||||
toast.error('All uploads failed');
|
||||
}
|
||||
},
|
||||
[bunnyCdnHostname, cleanupUploadState, directUploadProvider, projectsById, router]
|
||||
[bunnyCdnHostname, directUploadProvider, fixedProjectId, projectsById, resetUploadState, router]
|
||||
);
|
||||
|
||||
const handleDropFile = useCallback(
|
||||
(file: File) => {
|
||||
const handleDropFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (!canUpload) {
|
||||
toast.error('You do not have permission to upload videos here');
|
||||
return;
|
||||
}
|
||||
|
||||
setDroppedFile(file);
|
||||
const videoFiles = files.filter(isVideoFile);
|
||||
const invalidCount = files.length - videoFiles.length;
|
||||
|
||||
if (fixedProjectId) {
|
||||
void uploadFileToProject(file, fixedProjectId, fixedProjectName);
|
||||
if (videoFiles.length === 0) {
|
||||
toast.error('Please drop valid video files');
|
||||
return;
|
||||
}
|
||||
|
||||
if (invalidCount > 0) {
|
||||
toast.error(`${invalidCount} file${invalidCount === 1 ? '' : 's'} skipped (not a video)`);
|
||||
}
|
||||
|
||||
if (fixedProjectId) {
|
||||
void uploadQueueToProject(videoFiles, fixedProjectId, fixedProjectName);
|
||||
return;
|
||||
}
|
||||
|
||||
setQueue(videoFiles.map(createQueueItem));
|
||||
setDialogOpen(true);
|
||||
void ensureProjectsLoaded();
|
||||
},
|
||||
[canUpload, ensureProjectsLoaded, fixedProjectId, fixedProjectName, uploadFileToProject]
|
||||
[canUpload, ensureProjectsLoaded, fixedProjectId, fixedProjectName, uploadQueueToProject]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -520,13 +406,10 @@ export function VideoDragDropUploader({
|
||||
dragDepthRef.current = 0;
|
||||
setIsDragActive(false);
|
||||
|
||||
const videoFile = extractVideoFile(event.dataTransfer);
|
||||
if (!videoFile) {
|
||||
toast.error('Please drop a valid video file');
|
||||
return;
|
||||
}
|
||||
const allFiles = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (allFiles.length === 0) return;
|
||||
|
||||
handleDropFile(videoFile);
|
||||
handleDropFiles(allFiles);
|
||||
};
|
||||
|
||||
window.addEventListener('dragenter', handleDragEnter);
|
||||
@@ -540,7 +423,15 @@ export function VideoDragDropUploader({
|
||||
window.removeEventListener('dragleave', handleDragLeave);
|
||||
window.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, [handleDropFile]);
|
||||
}, [handleDropFiles]);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setQueue([]);
|
||||
setUploadStatus('');
|
||||
setUploadProgress(0);
|
||||
setSelectedProjectId(fixedProjectId ?? null);
|
||||
setSelectedProjectName(fixedProjectName ?? null);
|
||||
}, [fixedProjectId, fixedProjectName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -549,11 +440,11 @@ export function VideoDragDropUploader({
|
||||
<div className="flex h-full items-center justify-center px-4">
|
||||
<div className="w-full max-w-2xl rounded-2xl border-2 border-dashed border-primary bg-background p-10 text-center shadow-2xl">
|
||||
<UploadCloud className="mx-auto mb-4 h-10 w-10 text-primary" />
|
||||
<p className="text-lg font-semibold">Drop video to upload</p>
|
||||
<p className="text-lg font-semibold">Drop videos to upload</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{fixedProjectId
|
||||
? `Upload to ${fixedProjectName ?? 'current project'}`
|
||||
: 'Drop now, then choose a project card.'}
|
||||
? `Upload multiple videos to ${fixedProjectName ?? 'current project'}`
|
||||
: 'Drop multiple videos, then choose a project.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -568,26 +459,62 @@ export function VideoDragDropUploader({
|
||||
setShowCancelUploadDialog(true);
|
||||
return;
|
||||
}
|
||||
setDroppedFile(null);
|
||||
setUploadStatus('');
|
||||
setUploadProgress(0);
|
||||
setSelectedProjectId(fixedProjectId ?? null);
|
||||
setSelectedProjectName(fixedProjectName ?? null);
|
||||
closeDialog();
|
||||
}
|
||||
setDialogOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="border-2 border-border bg-background text-foreground sm:max-w-xl">
|
||||
<DialogHeader className="space-y-1">
|
||||
<DialogTitle className="text-2xl font-bold">Choose a project</DialogTitle>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{needsProjectSelection ? 'Choose a project' : 'Uploading videos'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{droppedFile
|
||||
? 'Upload 1 video to:'
|
||||
: 'Drop a video file anywhere on this page to start.'}
|
||||
{hasQueue
|
||||
? totalCount === 1
|
||||
? `Upload 1 video${needsProjectSelection ? ' to:' : ''}`
|
||||
: `Upload ${totalCount} videos${needsProjectSelection ? ' to:' : ''}`
|
||||
: 'Drop video files anywhere on this page to start.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{hasQueue && (
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border">
|
||||
{queue.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
{item.status === 'done' ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />
|
||||
) : item.status === 'error' ? (
|
||||
<XCircle className="h-4 w-4 shrink-0 text-destructive" />
|
||||
) : item.status === 'uploading' ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
) : item.status === 'cancelled' ? (
|
||||
<XCircle className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="h-4 w-4 shrink-0 rounded-full border border-muted-foreground/40" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{item.file.name}</p>
|
||||
{item.error ? (
|
||||
<p className="truncate text-xs text-destructive">{item.error}</p>
|
||||
) : (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{getDefaultTitleFromFile(item.file)}
|
||||
{item.status === 'uploading' && item.progress > 0
|
||||
? ` · ${item.progress}%`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fixedProjectId && (
|
||||
<div className="space-y-2">
|
||||
{isLoadingProjects ? (
|
||||
@@ -597,23 +524,31 @@ export function VideoDragDropUploader({
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
tabIndex={droppedFile && !isUploading ? 0 : -1}
|
||||
disabled={!droppedFile || isUploading}
|
||||
aria-disabled={!droppedFile || isUploading}
|
||||
className={`block w-full border-b border-border p-4 text-left transition-colors last:border-b-0 ${selectedProjectId === project.id ? 'bg-accent' : 'bg-background'} ${!droppedFile || isUploading ? 'opacity-60' : 'hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'}`}
|
||||
tabIndex={hasQueue && !isUploading ? 0 : -1}
|
||||
disabled={!hasQueue || isUploading || pendingCount === 0}
|
||||
aria-disabled={!hasQueue || isUploading || pendingCount === 0}
|
||||
className={`block w-full border-b border-border p-4 text-left transition-colors last:border-b-0 ${selectedProjectId === project.id ? 'bg-accent' : 'bg-background'} ${!hasQueue || isUploading ? 'opacity-60' : 'hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'}`}
|
||||
onClick={() => {
|
||||
if (!droppedFile || isUploading) return;
|
||||
if (!hasQueue || isUploading) return;
|
||||
const pendingFiles = queue
|
||||
.filter((item) => item.status === 'pending')
|
||||
.map((item) => item.file);
|
||||
if (pendingFiles.length === 0) return;
|
||||
setSelectedProjectId(project.id);
|
||||
setSelectedProjectName(project.name);
|
||||
void uploadFileToProject(droppedFile, project.id, project.name);
|
||||
void uploadQueueToProject(pendingFiles, project.id, project.name);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
if (!droppedFile || isUploading) return;
|
||||
if (!hasQueue || isUploading) return;
|
||||
event.preventDefault();
|
||||
const pendingFiles = queue
|
||||
.filter((item) => item.status === 'pending')
|
||||
.map((item) => item.file);
|
||||
if (pendingFiles.length === 0) return;
|
||||
setSelectedProjectId(project.id);
|
||||
setSelectedProjectName(project.name);
|
||||
void uploadFileToProject(droppedFile, project.id, project.name);
|
||||
void uploadQueueToProject(pendingFiles, project.id, project.name);
|
||||
}}
|
||||
>
|
||||
<p className="text-2xl font-semibold leading-tight text-foreground">
|
||||
@@ -656,12 +591,27 @@ export function VideoDragDropUploader({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{totalCount > 1 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{doneCount} of {totalCount} complete
|
||||
{errorCount > 0 ? ` · ${errorCount} failed` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fixedProjectId && !isUploading && droppedFile && (
|
||||
{!fixedProjectId && !isUploading && hasQueue && pendingCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click a project card to start uploading.
|
||||
Click a project card to start uploading {pendingCount} video
|
||||
{pendingCount === 1 ? '' : 's'}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isUploading && hasQueue && (doneCount > 0 || errorCount > 0) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{doneCount > 0 ? `${doneCount} uploaded` : ''}
|
||||
{doneCount > 0 && errorCount > 0 ? ', ' : ''}
|
||||
{errorCount > 0 ? `${errorCount} failed` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -673,8 +623,9 @@ export function VideoDragDropUploader({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel upload?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
A video upload is in progress. If you cancel now, the current upload will be
|
||||
discarded.
|
||||
{totalCount > 1
|
||||
? 'Video uploads are in progress. If you cancel now, the current upload and any remaining queued files will be discarded.'
|
||||
: 'A video upload is in progress. If you cancel now, the current upload will be discarded.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -925,6 +925,7 @@ export function VideoPageContent({
|
||||
loadMoreAssets={loadMoreAssets}
|
||||
highlightedAssetId={highlightedAssetId}
|
||||
onHighlightedAssetHandled={() => setHighlightedAssetId(null)}
|
||||
directUploadProvider={directUploadProvider}
|
||||
/>
|
||||
}
|
||||
composer={
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
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 type { DirectUploadProvider, VideoAsset } from '@/components/video-page/types';
|
||||
import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload';
|
||||
import {
|
||||
extractPastedImageFile,
|
||||
validateImageFile,
|
||||
@@ -87,12 +88,13 @@ interface AssetsPaneProps {
|
||||
canDownloadAssets: boolean;
|
||||
getGuestUploadToken: (intent: 'image' | 'audio') => Promise<string | null>;
|
||||
createAsset: (payload: {
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
|
||||
displayName?: string;
|
||||
sourceUrl: string;
|
||||
providerVideoId?: string;
|
||||
thumbnailUrl?: string;
|
||||
uploadToken?: string;
|
||||
objectKey?: string;
|
||||
reservationId?: string | null;
|
||||
}) => Promise<VideoAsset | null>;
|
||||
deleteAsset: (assetId: string) => Promise<boolean>;
|
||||
@@ -102,6 +104,7 @@ interface AssetsPaneProps {
|
||||
loadMoreAssets: () => Promise<void>;
|
||||
highlightedAssetId: string | null;
|
||||
onHighlightedAssetHandled: () => void;
|
||||
directUploadProvider?: DirectUploadProvider;
|
||||
}
|
||||
|
||||
export const AssetsPane = memo(function AssetsPane({
|
||||
@@ -122,16 +125,18 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
loadMoreAssets,
|
||||
highlightedAssetId,
|
||||
onHighlightedAssetHandled,
|
||||
directUploadProvider = 'bunny',
|
||||
}: AssetsPaneProps) {
|
||||
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny' | 'voice'>('image');
|
||||
const [imageTitle, setImageTitle] = useState('');
|
||||
const [pendingImageFile, setPendingImageFile] = useState<File | null>(null);
|
||||
const [pendingImageFiles, setPendingImageFiles] = useState<File[]>([]);
|
||||
const [youtubeUrl, setYoutubeUrl] = useState('');
|
||||
const [youtubeTitle, setYoutubeTitle] = useState('');
|
||||
const [bunnyTitle, setBunnyTitle] = useState('');
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
|
||||
const [bunnyProgress, setBunnyProgress] = useState(0);
|
||||
const [bunnyUploadLabel, setBunnyUploadLabel] = useState('');
|
||||
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
@@ -152,6 +157,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false });
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const bunnyInputRef = useRef<HTMLInputElement>(null);
|
||||
const voiceInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Voice recording state
|
||||
const [voiceTitle, setVoiceTitle] = useState('');
|
||||
@@ -159,7 +165,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
|
||||
const [pendingAudioFile, setPendingAudioFile] = useState<File | null>(null);
|
||||
const [pendingAudioFiles, setPendingAudioFiles] = useState<File[]>([]);
|
||||
const [isUploadingVoice, setIsUploadingVoice] = useState(false);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<BlobPart[]>([]);
|
||||
@@ -339,17 +345,14 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
);
|
||||
}, [bunnyReadyByAssetId, selectedAsset]);
|
||||
|
||||
const handleImageUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (!file) return;
|
||||
|
||||
const uploadSingleImageAsset = useCallback(
|
||||
async (file: File, displayName?: string): Promise<boolean> => {
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
toast.error(`${file.name}: ${imageError}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsUploadingImage(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
@@ -367,39 +370,99 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
} | null;
|
||||
const uploadedImageUrl = uploadPayload?.data?.url;
|
||||
if (!uploadRes.ok || !uploadedImageUrl) {
|
||||
toast.error(uploadPayload?.error || 'Failed to upload image');
|
||||
return;
|
||||
toast.error(`${file.name}: ${uploadPayload?.error || 'Failed to upload image'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await createAsset({
|
||||
const created = await createAsset({
|
||||
provider: 'R2_IMAGE',
|
||||
sourceUrl: uploadedImageUrl,
|
||||
displayName: imageTitle.trim() || file.name,
|
||||
displayName: displayName?.trim() || file.name,
|
||||
reservationId: uploadPayload?.data?.reservationId ?? null,
|
||||
});
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
setImageTitle('');
|
||||
setPendingImageFile(null);
|
||||
return !!created;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image asset:', error);
|
||||
toast.error('Failed to upload image');
|
||||
toast.error(`${file.name}: Failed to upload image`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[videoId, getGuestUploadToken, createAsset]
|
||||
);
|
||||
|
||||
const handleImageUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setIsUploadingImage(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const displayName = imageTitle.trim() || file.name;
|
||||
const ok = await uploadSingleImageAsset(file, displayName);
|
||||
if (ok) successCount += 1;
|
||||
else failCount += 1;
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
setImageTitle('');
|
||||
setPendingImageFiles([]);
|
||||
}
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(successCount === 1 ? 'Image uploaded' : `${successCount} images uploaded`);
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`${successCount} uploaded, ${failCount} failed`);
|
||||
}
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
},
|
||||
[videoId, getGuestUploadToken, createAsset, imageTitle]
|
||||
[imageTitle, uploadSingleImageAsset]
|
||||
);
|
||||
|
||||
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
const stageImageFiles = useCallback(async (files: File[]) => {
|
||||
const validFiles: File[] = [];
|
||||
for (const file of files) {
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(`${file.name}: ${imageError}`);
|
||||
continue;
|
||||
}
|
||||
validFiles.push(file);
|
||||
}
|
||||
setPendingImageFile(file);
|
||||
toast.success('Image attached. Click Upload Image to send.');
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
setPendingImageFiles((prev) => {
|
||||
const next = [...prev];
|
||||
for (const file of validFiles) {
|
||||
const duplicate = next.some(
|
||||
(existing) =>
|
||||
existing.name === file.name &&
|
||||
existing.size === file.size &&
|
||||
existing.lastModified === file.lastModified
|
||||
);
|
||||
if (!duplicate) next.push(file);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
toast.success(
|
||||
validFiles.length === 1
|
||||
? 'Image attached. Click Upload to send.'
|
||||
: `${validFiles.length} images attached. Click Upload to send.`
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
setUploadTab('image');
|
||||
await stageImageFiles(files);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleImagePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
@@ -407,13 +470,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
const pastedImage = extractPastedImageFile(event.clipboardData);
|
||||
if (!pastedImage) return;
|
||||
event.preventDefault();
|
||||
const imageError = await validateImageFile(pastedImage);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
setPendingImageFile(pastedImage);
|
||||
toast.success('Image attached from clipboard. Click Upload Image to send.');
|
||||
await stageImageFiles([pastedImage]);
|
||||
};
|
||||
|
||||
const handleCreateYoutubeAsset = async () => {
|
||||
@@ -430,10 +487,10 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
};
|
||||
|
||||
const handleBunnyFileUpload = useCallback(
|
||||
async (file: File) => {
|
||||
async (file: File, options?: { index?: number; total?: number }) => {
|
||||
if (!file.type.startsWith('video/')) {
|
||||
toast.error('Please select a video file');
|
||||
return;
|
||||
toast.error(`${file.name}: Please select a video file`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let uploadedVideoId: string | null = null;
|
||||
@@ -441,6 +498,11 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
try {
|
||||
setIsUploadingBunny(true);
|
||||
setBunnyProgress(0);
|
||||
if (options?.total && options.total > 1) {
|
||||
setBunnyUploadLabel(`Uploading ${options.index ?? 1} of ${options.total}: ${file.name}`);
|
||||
} else {
|
||||
setBunnyUploadLabel('');
|
||||
}
|
||||
|
||||
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
||||
method: 'POST',
|
||||
@@ -459,8 +521,8 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
} | null;
|
||||
|
||||
if (!initRes.ok || !initPayload?.data) {
|
||||
toast.error(initPayload?.error || 'Failed to initialize Bunny upload');
|
||||
return;
|
||||
toast.error(`${file.name}: ${initPayload?.error || 'Failed to initialize Bunny upload'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const initData = initPayload.data;
|
||||
@@ -508,11 +570,10 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
}
|
||||
setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false }));
|
||||
setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true }));
|
||||
if (bunnyInputRef.current) bunnyInputRef.current.value = '';
|
||||
setBunnyTitle('');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload Bunny asset:', error);
|
||||
toast.error('Failed to upload Bunny video');
|
||||
toast.error(`${file.name}: Failed to upload Bunny video`);
|
||||
if (uploadedVideoId && uploadToken) {
|
||||
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
@@ -520,18 +581,109 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsUploadingBunny(false);
|
||||
setBunnyProgress(0);
|
||||
setBunnyUploadLabel('');
|
||||
}
|
||||
},
|
||||
[videoId, bunnyTitle, bunnyCdnHostname, createAsset]
|
||||
);
|
||||
|
||||
const handleR2FileUpload = useCallback(
|
||||
async (file: File, options?: { index?: number; total?: number }) => {
|
||||
if (!file.type.startsWith('video/')) {
|
||||
toast.error(`${file.name}: Please select a video file`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploadingBunny(true);
|
||||
setBunnyProgress(0);
|
||||
if (options?.total && options.total > 1) {
|
||||
setBunnyUploadLabel(`Uploading ${options.index ?? 1} of ${options.total}: ${file.name}`);
|
||||
} else {
|
||||
setBunnyUploadLabel('');
|
||||
}
|
||||
|
||||
const uploadResult = await uploadAssetVideoToR2(videoId, file, {
|
||||
onProgress: (progress) => setBunnyProgress(progress),
|
||||
});
|
||||
|
||||
const createdAsset = await createAsset({
|
||||
provider: 'R2_VIDEO',
|
||||
sourceUrl: uploadResult.proxyUrl,
|
||||
objectKey: uploadResult.objectKey,
|
||||
uploadToken: uploadResult.uploadToken,
|
||||
reservationId: uploadResult.reservationId,
|
||||
thumbnailUrl: uploadResult.thumbnailUrl ?? undefined,
|
||||
displayName: bunnyTitle.trim() || file.name,
|
||||
});
|
||||
if (!createdAsset) {
|
||||
throw new Error('Failed to finalize video asset');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload R2 asset video:', error);
|
||||
toast.error(`${file.name}: Failed to upload video`);
|
||||
return false;
|
||||
} finally {
|
||||
setIsUploadingBunny(false);
|
||||
setBunnyProgress(0);
|
||||
setBunnyUploadLabel('');
|
||||
}
|
||||
},
|
||||
[videoId, bunnyTitle, createAsset]
|
||||
);
|
||||
|
||||
const handleVideoFileUpload = useCallback(
|
||||
(file: File, options?: { index?: number; total?: number }) => {
|
||||
if (directUploadProvider === 'r2') {
|
||||
return handleR2FileUpload(file, options);
|
||||
}
|
||||
return handleBunnyFileUpload(file, options);
|
||||
},
|
||||
[directUploadProvider, handleBunnyFileUpload, handleR2FileUpload]
|
||||
);
|
||||
|
||||
const handleVideoBatchUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const ok = await handleVideoFileUpload(files[index], {
|
||||
index: index + 1,
|
||||
total: files.length,
|
||||
});
|
||||
if (ok) successCount += 1;
|
||||
else failCount += 1;
|
||||
}
|
||||
|
||||
if (bunnyInputRef.current) bunnyInputRef.current.value = '';
|
||||
if (successCount > 0) setBunnyTitle('');
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(successCount === 1 ? 'Video uploaded' : `${successCount} videos uploaded`);
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`${successCount} uploaded, ${failCount} failed`);
|
||||
}
|
||||
},
|
||||
[handleVideoFileUpload]
|
||||
);
|
||||
|
||||
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
await handleBunnyFileUpload(file);
|
||||
const files = Array.from(event.target.files ?? []).filter((file) =>
|
||||
file.type.startsWith('video/')
|
||||
);
|
||||
if (files.length === 0) {
|
||||
toast.error('Please select valid video files');
|
||||
return;
|
||||
}
|
||||
await handleVideoBatchUpload(files);
|
||||
};
|
||||
|
||||
const handleBunnyThumbnailError = (assetId: string) => {
|
||||
@@ -605,73 +757,157 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setPendingAudioFile(null);
|
||||
setPendingAudioFiles([]);
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVoiceUpload = useCallback(async () => {
|
||||
const uploadSource = pendingAudioFile ?? audioBlob;
|
||||
if (!uploadSource) return;
|
||||
const uploadSingleAudioAsset = useCallback(
|
||||
async (file: File | Blob, fileName: string, displayName?: string): Promise<boolean> => {
|
||||
const validationError = getAudioUploadValidationError(file);
|
||||
if (validationError) {
|
||||
toast.error(`${fileName}: ${validationError}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const validationError = getAudioUploadValidationError(uploadSource);
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', file, file instanceof File ? file.name : 'recording.webm');
|
||||
formData.append('videoId', videoId);
|
||||
const guestUploadToken = await getGuestUploadToken('audio');
|
||||
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
|
||||
|
||||
const uploadRes = await fetch('/api/upload/audio', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const uploadPayload = await readUploadAudioResponse(uploadRes);
|
||||
const uploadedUrl = uploadPayload?.data?.url;
|
||||
if (!uploadRes.ok || !uploadedUrl) {
|
||||
toast.error(
|
||||
`${fileName}: ${
|
||||
uploadPayload?.error ||
|
||||
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
|
||||
'Failed to upload voice recording'
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const created = await createAsset({
|
||||
provider: 'R2_AUDIO',
|
||||
sourceUrl: uploadedUrl,
|
||||
displayName:
|
||||
displayName?.trim() || fileName.replace(/\.[^/.]+$/, '') || 'Voice Recording',
|
||||
reservationId: uploadPayload?.data?.reservationId ?? null,
|
||||
});
|
||||
return !!created;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload voice asset:', error);
|
||||
toast.error(`${fileName}: Failed to upload voice recording`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[videoId, getGuestUploadToken, createAsset]
|
||||
);
|
||||
|
||||
const stageAudioFiles = useCallback((files: File[]) => {
|
||||
const validFiles: File[] = [];
|
||||
for (const file of files) {
|
||||
const audioError = getAudioUploadValidationError(file);
|
||||
if (audioError) {
|
||||
toast.error(`${file.name}: ${audioError}`);
|
||||
continue;
|
||||
}
|
||||
validFiles.push(file);
|
||||
}
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
setPendingAudioFiles((prev) => {
|
||||
const next = [...prev];
|
||||
for (const file of validFiles) {
|
||||
const duplicate = next.some(
|
||||
(existing) =>
|
||||
existing.name === file.name &&
|
||||
existing.size === file.size &&
|
||||
existing.lastModified === file.lastModified
|
||||
);
|
||||
if (!duplicate) next.push(file);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
toast.success(
|
||||
validFiles.length === 1
|
||||
? 'Audio file attached. Click Upload to send.'
|
||||
: `${validFiles.length} audio files attached. Click Upload to send.`
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleVoiceFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
setUploadTab('voice');
|
||||
stageAudioFiles(files);
|
||||
if (voiceInputRef.current) voiceInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleVoiceUpload = useCallback(async () => {
|
||||
if (audioBlob && pendingAudioFiles.length === 0) {
|
||||
setIsUploadingVoice(true);
|
||||
try {
|
||||
const ok = await uploadSingleAudioAsset(
|
||||
audioBlob,
|
||||
'recording.webm',
|
||||
voiceTitle.trim() || 'Voice Recording'
|
||||
);
|
||||
if (ok) {
|
||||
setVoiceTitle('');
|
||||
setAudioBlob(null);
|
||||
setAudioBlobUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsUploadingVoice(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingAudioFiles.length === 0) return;
|
||||
|
||||
setIsUploadingVoice(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (pendingAudioFile) {
|
||||
formData.append('audio', pendingAudioFile);
|
||||
} else {
|
||||
formData.append('audio', audioBlob!, 'recording.webm');
|
||||
for (const file of pendingAudioFiles) {
|
||||
const displayName = voiceTitle.trim() || file.name.replace(/\.[^/.]+$/, '');
|
||||
const ok = await uploadSingleAudioAsset(file, file.name, displayName);
|
||||
if (ok) successCount += 1;
|
||||
else failCount += 1;
|
||||
}
|
||||
formData.append('videoId', videoId);
|
||||
const guestUploadToken = await getGuestUploadToken('audio');
|
||||
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
|
||||
|
||||
const uploadRes = await fetch('/api/upload/audio', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const uploadPayload = await readUploadAudioResponse(uploadRes);
|
||||
const uploadedUrl = uploadPayload?.data?.url;
|
||||
if (!uploadRes.ok || !uploadedUrl) {
|
||||
toast.error(
|
||||
uploadPayload?.error ||
|
||||
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
|
||||
'Failed to upload voice recording'
|
||||
if (successCount > 0) {
|
||||
setVoiceTitle('');
|
||||
setPendingAudioFiles([]);
|
||||
if (voiceInputRef.current) voiceInputRef.current.value = '';
|
||||
}
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(
|
||||
successCount === 1 ? 'Audio uploaded' : `${successCount} audio files uploaded`
|
||||
);
|
||||
return;
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`${successCount} uploaded, ${failCount} failed`);
|
||||
}
|
||||
|
||||
const fallbackName = pendingAudioFile
|
||||
? pendingAudioFile.name.replace(/\.[^/.]+$/, '')
|
||||
: 'Voice Recording';
|
||||
await createAsset({
|
||||
provider: 'R2_AUDIO',
|
||||
sourceUrl: uploadedUrl,
|
||||
displayName: voiceTitle.trim() || fallbackName,
|
||||
reservationId: uploadPayload?.data?.reservationId ?? null,
|
||||
});
|
||||
setVoiceTitle('');
|
||||
setAudioBlob(null);
|
||||
setAudioBlobUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setPendingAudioFile(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload voice asset:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to upload voice recording');
|
||||
} finally {
|
||||
setIsUploadingVoice(false);
|
||||
}
|
||||
}, [pendingAudioFile, audioBlob, videoId, getGuestUploadToken, createAsset, voiceTitle]);
|
||||
}, [audioBlob, pendingAudioFiles, uploadSingleAudioAsset, voiceTitle]);
|
||||
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@@ -700,36 +936,52 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
setIsDragOver(false);
|
||||
if (!canUploadAssets) return;
|
||||
|
||||
const file = Array.from(e.dataTransfer.files)[0];
|
||||
if (!file) return;
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
const imageFiles: File[] = [];
|
||||
const videoFiles: File[] = [];
|
||||
const audioFiles: File[] = [];
|
||||
let unsupportedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
imageFiles.push(file);
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
videoFiles.push(file);
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
audioFiles.push(file);
|
||||
} else {
|
||||
unsupportedCount += 1;
|
||||
toast.error(`${file.name}: Unsupported file type`);
|
||||
}
|
||||
// Stage the file so the user can optionally set a name before uploading
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
setUploadTab('image');
|
||||
setPendingImageFile(file);
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
// Videos upload immediately (large files, no staging)
|
||||
setUploadTab('bunny');
|
||||
await handleBunnyFileUpload(file);
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
const audioError = getAudioUploadValidationError(file);
|
||||
if (audioError) {
|
||||
toast.error(audioError);
|
||||
return;
|
||||
}
|
||||
// Stage the file so the user can optionally set a name before uploading
|
||||
await stageImageFiles(imageFiles);
|
||||
}
|
||||
|
||||
if (audioFiles.length > 0) {
|
||||
setUploadTab('voice');
|
||||
setPendingAudioFile(file);
|
||||
} else {
|
||||
toast.error('Unsupported file type. Drop an image, video, or audio file.');
|
||||
stageAudioFiles(audioFiles);
|
||||
}
|
||||
|
||||
if (videoFiles.length > 0) {
|
||||
setUploadTab('bunny');
|
||||
await handleVideoBatchUpload(videoFiles);
|
||||
}
|
||||
|
||||
if (
|
||||
imageFiles.length === 0 &&
|
||||
videoFiles.length === 0 &&
|
||||
audioFiles.length === 0 &&
|
||||
unsupportedCount === 0
|
||||
) {
|
||||
toast.error('Drop an image, video, or audio file.');
|
||||
}
|
||||
},
|
||||
[canUploadAssets, handleBunnyFileUpload]
|
||||
[canUploadAssets, handleVideoBatchUpload, stageAudioFiles, stageImageFiles]
|
||||
);
|
||||
|
||||
const renderAssetPreview = (asset: VideoAsset) => {
|
||||
@@ -772,6 +1024,27 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.provider === 'R2_VIDEO') {
|
||||
const thumbnailSrc = asset.thumbnailUrl;
|
||||
return (
|
||||
<div className="h-24 w-36 rounded border overflow-hidden bg-black/70 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"
|
||||
/>
|
||||
) : (
|
||||
<FileVideo className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||
<Play className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0;
|
||||
const isProcessing = !!bunnyProcessingByAssetId[asset.id];
|
||||
const isReadyToPlay = !!bunnyReadyByAssetId[asset.id];
|
||||
@@ -863,7 +1136,10 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-primary/10 border-2 border-dashed border-primary pointer-events-none">
|
||||
<UploadCloud className="h-8 w-8 text-primary" />
|
||||
<span className="text-sm font-medium text-primary">Drop to upload</span>
|
||||
<span className="text-sm font-medium text-primary">Drop files to upload</span>
|
||||
<span className="text-xs text-primary/80">
|
||||
Multiple images, videos, or audio supported
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
@@ -891,22 +1167,40 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
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.
|
||||
Tip: paste an image with Ctrl/Cmd+V, or drop multiple files onto this panel.
|
||||
</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>
|
||||
{pendingImageFiles.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{pendingImageFiles.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
|
||||
className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="truncate">Attached: {file.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2"
|
||||
onClick={() => {
|
||||
setPendingImageFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setPendingImageFile(null);
|
||||
setPendingImageFiles([]);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -915,8 +1209,8 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
className="w-full"
|
||||
disabled={isUploadingImage || isCreatingAsset}
|
||||
onClick={() => {
|
||||
if (pendingImageFile) {
|
||||
void handleImageUpload(pendingImageFile);
|
||||
if (pendingImageFiles.length > 0) {
|
||||
void handleImageUpload(pendingImageFiles);
|
||||
return;
|
||||
}
|
||||
imageInputRef.current?.click();
|
||||
@@ -931,14 +1225,17 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
? 'Uploading...'
|
||||
: isCreatingAsset
|
||||
? 'Saving...'
|
||||
: pendingImageFile
|
||||
? 'Upload Image'
|
||||
: 'Select Image'}
|
||||
: pendingImageFiles.length > 1
|
||||
? `Upload ${pendingImageFiles.length} Images`
|
||||
: pendingImageFiles.length === 1
|
||||
? 'Upload Image'
|
||||
: 'Select Images'}
|
||||
</Button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleImageFileChange}
|
||||
/>
|
||||
@@ -978,6 +1275,9 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
<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">
|
||||
Drop multiple video files onto this panel to upload them in sequence.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
@@ -989,21 +1289,27 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
) : (
|
||||
<UploadCloud className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isUploadingBunny ? 'Uploading...' : 'Upload Video'}
|
||||
{isUploadingBunny ? 'Uploading...' : 'Select Videos'}
|
||||
</Button>
|
||||
<input
|
||||
ref={bunnyInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
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 className="space-y-1">
|
||||
{bunnyUploadLabel ? (
|
||||
<p className="text-xs text-muted-foreground">{bunnyUploadLabel}</p>
|
||||
) : null}
|
||||
<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>
|
||||
@@ -1017,31 +1323,54 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
onChange={(e) => setVoiceTitle(e.target.value)}
|
||||
disabled={isRecording || isUploadingVoice}
|
||||
/>
|
||||
{pendingAudioFile ? (
|
||||
{pendingAudioFiles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
|
||||
<span className="truncate">Attached: {pendingAudioFile.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setPendingAudioFile(null)}
|
||||
{pendingAudioFiles.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
|
||||
className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<span className="truncate">Attached: {file.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2"
|
||||
onClick={() => {
|
||||
setPendingAudioFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setPendingAudioFiles([]);
|
||||
if (voiceInputRef.current) voiceInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={isUploadingVoice || isCreatingAsset}
|
||||
onClick={handleVoiceUpload}
|
||||
onClick={() => void handleVoiceUpload()}
|
||||
>
|
||||
{isUploadingVoice ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<UploadCloud className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isUploadingVoice ? 'Uploading...' : 'Upload File'}
|
||||
{isUploadingVoice
|
||||
? 'Uploading...'
|
||||
: pendingAudioFiles.length > 1
|
||||
? `Upload ${pendingAudioFiles.length} Files`
|
||||
: 'Upload File'}
|
||||
</Button>
|
||||
</div>
|
||||
) : isRecording ? (
|
||||
@@ -1141,18 +1470,37 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={startRecording}
|
||||
disabled={isUploadingVoice || isCreatingAsset}
|
||||
>
|
||||
<Mic className="h-4 w-4 mr-2" />
|
||||
Start Recording
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={startRecording}
|
||||
disabled={isUploadingVoice || isCreatingAsset}
|
||||
>
|
||||
<Mic className="h-4 w-4 mr-2" />
|
||||
Start Recording
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isUploadingVoice || isCreatingAsset}
|
||||
onClick={() => voiceInputRef.current?.click()}
|
||||
>
|
||||
<UploadCloud className="h-4 w-4 mr-2" />
|
||||
Select Audio Files
|
||||
</Button>
|
||||
<input
|
||||
ref={voiceInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleVoiceFileChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or drag an audio file anywhere onto this panel.
|
||||
Or drag multiple audio files anywhere onto this panel.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1286,6 +1634,22 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
Open on YouTube
|
||||
</a>
|
||||
</Button>
|
||||
) : selectedAsset?.provider === 'R2_VIDEO' && canDownloadAssets ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title="Download video"
|
||||
aria-label="Download video"
|
||||
disabled={activeDownloadAssetId === selectedAsset.id}
|
||||
onClick={() => void downloadAsset(selectedAsset)}
|
||||
>
|
||||
{activeDownloadAssetId === selectedAsset.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? (
|
||||
<DropdownMenu>
|
||||
@@ -1346,6 +1710,14 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
) : selectedAsset.provider === 'R2_VIDEO' && selectedAsset.sourceUrl ? (
|
||||
<video
|
||||
className="w-full h-full rounded-md border bg-black object-contain"
|
||||
src={selectedAsset.sourceUrl}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<BunnyPreviewPlayer
|
||||
ref={bunnyPreviewPlayerRef}
|
||||
|
||||
@@ -7,12 +7,13 @@ import type { VideoAsset } from '@/components/video-page/types';
|
||||
type BunnyDownloadPreference = 'original' | 'compressed';
|
||||
|
||||
type CreateAssetPayload = {
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
|
||||
displayName?: string;
|
||||
sourceUrl: string;
|
||||
providerVideoId?: string;
|
||||
thumbnailUrl?: string;
|
||||
uploadToken?: string;
|
||||
objectKey?: string;
|
||||
reservationId?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface VideoAsset {
|
||||
id: string;
|
||||
videoId: string;
|
||||
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
|
||||
displayName: string;
|
||||
sourceUrl: string | null;
|
||||
providerVideoId: string | null;
|
||||
|
||||
Reference in New Issue
Block a user